diff --git a/docs/design.md b/docs/design.md index 2602a4c..a6b448c 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 @@ -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. @@ -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 | @@ -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 @@ -179,7 +199,8 @@ 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 | @@ -187,3 +208,5 @@ Explain a failure and the next useful step without hype: "The test controller cl | [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 | diff --git a/site/astro.config.js b/site/astro.config.js index e924b5d..44be774 100644 --- a/site/astro.config.js +++ b/site/astro.config.js @@ -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', diff --git a/site/scripts/generate.js b/site/scripts/generate.js index 99bc3bb..804b186 100644 --- a/site/scripts/generate.js +++ b/site/scripts/generate.js @@ -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)); @@ -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('#'); @@ -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: `
${tachometer({ phase: 'connecting', progress: 0 })}
The needle hasn't moved. Nothing ran here.
` }, + 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 = [ diff --git a/site/src/components/Hero.astro b/site/src/components/Hero.astro new file mode 100644 index 0000000..87a2f99 --- /dev/null +++ b/site/src/components/Hero.astro @@ -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 ? ( +
+
+

+ {tagline &&

} +

+ {actions.map(({ link: href, text, variant }) => {text})} +
+

+
+ {pulls.map(({ name, curve, ghost, head, failed, caption }) => ( +
+
+ +

An example pull

+
+
+
+

{sheetNote(curve)}

+ {testCount(curve.total)} + {sheetSpan(curve, ghost)} +
+
+ This pull{ghost && Last pull, {timingDuration(ghost.time)}} + {caption} +
+
+ ))} + +
+
+) : ( + +)} + + + + diff --git a/site/src/components/SiteTitle.astro b/site/src/components/SiteTitle.astro new file mode 100644 index 0000000..292d990 --- /dev/null +++ b/site/src/components/SiteTitle.astro @@ -0,0 +1,18 @@ +--- +import { wordmark } from '../../../src/browser/markup.js'; +let { siteTitle, siteTitleHref } = Astro.locals.starlightRoute; +--- + + + + + diff --git a/site/src/styles/docs.css b/site/src/styles/docs.css index 20dac96..2ae68c5 100644 --- a/site/src/styles/docs.css +++ b/site/src/styles/docs.css @@ -1,17 +1,112 @@ -:root { - --sl-color-accent-low: #112c26; - --sl-color-accent: #53bd9d; - --sl-color-accent-high: #b1ebd8; - --sl-content-width: 50rem; - --sl-text-body: 1rem; - --sl-font: ui-sans-serif, system-ui, sans-serif; +/* Dyno docs: the same dyno cell as the reporter. Dark is the night-shift cell, light is the printed sheet. + Tokens and instruments come from the reporter's brand.css; this file maps them onto Starlight. + Starlight sets color-scheme from its theme toggle, which picks each light-dark() token. */ +@import '../../../src/browser/brand.css'; +@font-face { + font-family: 'Archivo Dyno'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + font-stretch: 62% 125%; + src: url('../../../src/browser/fonts/archivo.woff2') format('woff2'); } -:root[data-theme='light'] { - --sl-color-accent-low: #d8f2e8; - --sl-color-accent: #176d52; - --sl-color-accent-high: #104331; + +:root { + --sl-color-white: var(--ink); + --sl-color-gray-1: light-dark(#1d3042, #d6e0e9); + --sl-color-gray-2: light-dark(#33495c, #b9c8d5); + --sl-color-gray-3: var(--muted); + --sl-color-gray-4: var(--faint); + --sl-color-gray-5: var(--line); + --sl-color-gray-6: var(--surface); + --sl-color-gray-7: var(--raised); + --sl-color-black: light-dark(#fbfcfd, #141f2a); + --sl-color-accent-low: light-dark(#fff1c2, #3b3005); + --sl-color-accent: var(--accent); + --sl-color-accent-high: light-dark(#845e00, #ffe08a); + --sl-color-text-accent: var(--trace); + --sl-color-text-invert: var(--on-accent); + --sl-color-bg-nav: color-mix(in srgb, var(--sl-color-black) 94%, transparent); + --sl-color-bg-sidebar: var(--sl-color-black); + --sl-color-bg-inline-code: light-dark(#e8eef4, #223444); + --sl-color-hairline-light: var(--line); + --sl-color-hairline: var(--line); + --sl-color-hairline-shade: var(--line); + --sl-color-backdrop-overlay: light-dark(#0f1f2e66, #0b131bb0); + --sl-font: var(--sans); + --sl-font-mono: var(--mono); + --sl-content-width: 48rem; + --sl-text-h1: 2.5rem; + --sl-text-h2: 1.75rem; + --sl-text-h3: 1.3125rem; + --sl-text-h4: 1.125rem; + --sl-line-height-headings: 1.15; } + +body { font-stretch: 100%; -webkit-font-smoothing: antialiased; } +:focus-visible { outline: 2px solid var(--trace); outline-offset: 3px; } + +/* Starlight internals: these selectors follow Starlight's markup, so check them after upgrades. */ + +/* Header */ +header.header { border-bottom: 1px solid var(--line); } +site-search button[data-open-modal] { border-radius: 7px; border-color: var(--line); background: var(--surface); } + +/* Sidebar: the current page sits on a roller, the same mark as a running test. No rails on nested lists. */ +.sidebar-content .large { color: var(--ink); font-size: .9375rem; font-weight: 720; font-stretch: 110%; } +.sidebar-content ul ul li { border-inline-start: 0; } +.sidebar-content a { position: relative; border-radius: 6px; color: var(--sl-color-gray-2); font-size: .9375rem; } +.sidebar-content a:hover { color: var(--ink); } +.sidebar-content a[aria-current='page'] { color: var(--ink); background: none; font-weight: 680; } +.sidebar-content a[aria-current='page']::before { content: ''; position: absolute; left: -.6rem; top: 50%; width: 14px; height: 14px; translate: 0 -50%; background: var(--mark); -webkit-mask: var(--roller) center / contain no-repeat; mask: var(--roller) center / contain no-repeat; } + +/* Content */ .sl-markdown-content { line-height: 1.75; } -.sl-markdown-content h2 { margin-top: 2.5rem; } -.sl-markdown-content table { font-size: .875rem; } +.content-panel h1:not([data-page-title]), .sl-markdown-content :is(h1, h2, h3, h4) { letter-spacing: -.02em; } +.content-panel h1:not([data-page-title]) { font-weight: 780; font-stretch: 118%; text-wrap: balance; } +.sl-markdown-content h2 { margin-top: 2.75rem; font-weight: 740; font-stretch: 114%; } +.sl-markdown-content h3 { font-weight: 700; font-stretch: 108%; } +.sl-markdown-content h4 { font-weight: 680; } +.sl-markdown-content a:not(:where(.not-content *)) { text-underline-offset: 4px; text-decoration-thickness: 1px; } +.sl-markdown-content a:not(:where(.not-content *)):hover { text-decoration-thickness: 2px; } +.sl-markdown-content code:not(:where(.not-content *, pre *)) { padding: .1em .35em; border-radius: 4px; font-size: .875em; } +.sl-markdown-content table { display: table; width: 100%; font-size: .875rem; font-variant-numeric: tabular-nums; border-collapse: collapse; } +.sl-markdown-content :is(th, td) { padding: .6rem .75rem; border: 0; border-bottom: 1px solid var(--line); background: none; } +.sl-markdown-content th { color: var(--muted); font-size: .8125rem; font-weight: 600; text-align: start; } .sl-markdown-content table:has(a[id^="property-"]) td:nth-child(4) { min-width: 16rem; } +.sl-markdown-content hr { border-color: var(--line); } + +/* Callouts: tinted plates, no rails. Cautions get hazard tape, the cell's own "stop and look" sign. */ +.starlight-aside { position: relative; overflow: hidden; border: 1px solid var(--line); border-radius: 8px; } +.starlight-aside--note, .starlight-aside--tip { --sl-color-asides-text-accent: var(--trace); background: color-mix(in srgb, var(--trace) 7%, var(--surface)); } +.starlight-aside--caution { --sl-color-asides-text-accent: var(--accent-text); background: color-mix(in srgb, var(--accent) 8%, var(--surface)); } +.starlight-aside--danger { --sl-color-asides-text-accent: var(--fail); background: color-mix(in srgb, var(--fail) 8%, var(--surface)); } +.starlight-aside:is(.starlight-aside--caution, .starlight-aside--danger) { padding-top: 1.35rem; } +.starlight-aside:is(.starlight-aside--caution, .starlight-aside--danger)::before { content: ''; position: absolute; inset: 0 0 auto; height: 5px; background: var(--hazard); } + +/* Buttons: primary is a hazard-yellow plate, like Run all in the reporter. */ +.sl-link-button { border-radius: 7px; font-weight: 720; font-stretch: 108%; } +.sl-link-button.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); } +.sl-link-button.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--on-accent); } +.sl-link-button.secondary { border-color: var(--line); } +.sl-link-button.secondary:hover { border-color: var(--faint); } + +/* Pagination and table of contents */ +.pagination-links a { border-radius: 8px; border-color: var(--line); box-shadow: none; } +.pagination-links a:hover { border-color: var(--faint); } +.pagination-links .link-title { color: var(--ink); font-weight: 720; font-stretch: 110%; } +starlight-toc a[aria-current='true'] { color: var(--ink); font-weight: 650; } + +/* Splash pages: a faint sheet grid behind the hero, like printed dyno paper, and short reading lines below it. */ +[data-has-hero] .main-frame { position: relative; isolation: isolate; } +[data-has-hero] .main-frame::before { content: ''; position: absolute; inset: 0 0 auto; z-index: -1; height: 38rem; pointer-events: none; background-image: linear-gradient(var(--grid) 1px, transparent 1px), linear-gradient(90deg, var(--grid) 1px, transparent 1px); background-size: 48px 48px; mask-image: linear-gradient(to bottom, #000, transparent); -webkit-mask-image: linear-gradient(to bottom, #000, transparent); } +[data-has-hero] .sl-markdown-content { max-width: 46rem; } +.hero h1 { margin: 0; color: var(--ink); font-size: clamp(2.5rem, 1.4rem + 4vw, 4.25rem); line-height: 1; font-weight: 820; font-stretch: 122%; letter-spacing: -.035em; text-wrap: balance; } +.hero .tagline { margin: 0; max-width: 34rem; color: var(--sl-color-gray-2); font-size: clamp(1.0625rem, 1rem + .4vw, 1.25rem); line-height: 1.55; } + +/* The 404: a big tach whose needle never left zero. */ +.stalled { display: grid; justify-items: center; gap: 1rem; text-align: center; color: var(--muted); } +.stalled .tach { width: 180px; height: 102px; } +.stalled .tach-track { stroke-width: 12; } +.stalled .tach-ticks { stroke-width: 2.5; } +.stalled .tach-needle { stroke-width: 5; } diff --git a/src/browser/brand.css b/src/browser/brand.css new file mode 100644 index 0000000..ee8a347 --- /dev/null +++ b/src/browser/brand.css @@ -0,0 +1,82 @@ +/* Dyno's brand: tokens and the instruments shared by the reporter, the control room, saved reports, + and the docs site. Colors use light-dark(), so each page picks a theme by setting color-scheme: + the reporter follows the system, the control room stays dark, and the docs follow their theme toggle. + Pages bring their own @font-face (the URL differs) and size these pieces for their layout. */ +:root { + --bg: light-dark(#edf1f5, #141f2a); + --surface: light-dark(#ffffff, #1a2835); + --raised: light-dark(#f5f8fa, #223444); + --line: light-dark(#ccd6e0, #2a3d4f); + --grid: light-dark(#e1e8ef, #20303f); + --ink: light-dark(#0f1f2e, #e9eff4); + --muted: light-dark(#536a7e, #90a5b7); + --faint: light-dark(#8496a8, #62778b); + --trace: light-dark(#1a5bc4, #63a9ff); + --accent: #ffc629; + --accent-hover: #ffd457; + --on-accent: #1d1600; + --accent-text: light-dark(#845e00, #ffc629); + --mark: light-dark(#e0a400, #ffc629); + --pass: light-dark(#11804a, #4fd08a); + --fail: light-dark(#c8192e, #ff5c61); + --fail-tint: light-dark(#c8192e0d, #ff5c6114); + --hover: light-dark(#1a5bc40a, #63a9ff0d); + --hazard: repeating-linear-gradient(-45deg, var(--accent) 0 9px, var(--on-accent) 9px 18px); + --roller: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 40'%3E%3Ccircle cx='20' cy='20' r='15.5' fill='none' stroke='%23000' stroke-width='5.5'/%3E%3Cpath d='M20 4.5v5M20 30.5v5M4.5 20h5M30.5 20h5M9 9l3.5 3.5M27.5 27.5L31 31M31 9l-3.5 3.5M12.5 27.5L9 31' stroke='%23000' stroke-width='3' stroke-linecap='round'/%3E%3Ccircle cx='20' cy='20' r='4'/%3E%3C/svg%3E"); + --sans: 'Archivo Dyno', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; + --mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; +} +@property --progress { syntax: ''; inherits: true; initial-value: 0; } +@keyframes spin { to { rotate: 1turn; } } + +/* The wordmark: "dyn" with a roller drum for the o. */ +.wordmark { display: inline-flex; align-items: baseline; line-height: 1; font-weight: 860; font-stretch: 125%; letter-spacing: -.035em; transform: skewX(-9deg); } +.roller { width: .8em; height: .8em; margin-left: .04em; align-self: center; translate: 0 .1em; overflow: visible; } +.roller-drum { fill: none; stroke: var(--mark); stroke-width: 5.5; } +.roller-knurl { stroke: var(--mark); stroke-width: 3; stroke-linecap: round; transform-origin: 20px 20px; } +.roller-hub { fill: var(--mark); } + +/* The tachometer: the needle sweeps to all tests finished, and the arc takes the run's status color. */ +.tach { flex: none; overflow: visible; --tone: var(--faint); } +.tach path { fill: none; vector-effect: non-scaling-stroke; } +.tach-ticks { stroke: var(--muted); stroke-width: 1.6; stroke-linecap: round; } +.tach-track { stroke: var(--grid); stroke-width: 5; } +.tach-sweep { stroke: var(--tone); stroke-width: 5; stroke-dasharray: var(--progress, 0) 1; transition: stroke-dasharray .35s ease-out; } +.tach-needle { stroke: var(--ink); stroke-width: 2.4; stroke-linecap: round; transform-origin: 30px 31px; rotate: calc(-90deg + var(--progress, 0) * 180deg); transition: rotate .35s cubic-bezier(.3, 1.5, .5, 1); } +.tach-hub { fill: var(--ink); } +.tach:is([data-phase=running], [data-phase=discovering], [data-phase=pausing], [data-phase=resuming], [data-phase=paused], [data-phase=debugging]) { --tone: var(--accent); } +.tach[data-phase=completed] { --tone: var(--pass); } +.tach:is([data-phase=completed], [data-phase=stopped])[data-failed=true], .tach[data-phase=error] { --tone: var(--fail); } + +/* The dyno sheet. Size .sheet-frame where it's used. */ +.sheet-frame { position: relative; border-left: 1px solid var(--faint); border-bottom: 1px solid var(--faint); } +.sheet-canvas, .sheet-plot { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; } +.sheet-plot path { vector-effect: non-scaling-stroke; fill: none; } +.sheet-plot .sheet-area { fill: var(--trace); opacity: .07; stroke: none; } +.sheet-grid { stroke: var(--grid); stroke-width: 1; } +.sheet-ceiling { stroke: var(--faint); stroke-width: 1; stroke-dasharray: 3 5; } +.sheet-ghost { stroke: var(--faint); stroke-width: 1.5; stroke-dasharray: 2 5; stroke-linejoin: round; } +.sheet-trace { stroke: var(--trace); stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; } +.sheet-point { stroke: var(--trace); stroke-width: 6; stroke-linecap: round; } +.sheet-point[data-status=failed] { stroke: var(--fail); stroke-width: 11; } +.sheet-point:is([data-status=skipped], [data-status=stopped]) { stroke: var(--faint); } +.sheet-axis { position: absolute; color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; } +.sheet-axis-y { left: 8px; top: -20px; } +.sheet-axis-x { right: 0; bottom: -22px; } +.sheet-legend { display: flex; gap: 18px; } +.legend-trace, .legend-ghost { display: inline-flex; align-items: center; gap: 8px; } +.legend-trace::before, .legend-ghost::before { content: ''; width: 18px; } +.legend-trace::before { border-top: 2.5px solid var(--trace); } +.legend-ghost::before { border-top: 2px dotted var(--faint); } + +/* Sheet notes ride the curve like the peak note on a printed dyno sheet: a leader line, then plain text. */ +.sheet-flag { --flag-x: 8px; position: absolute; z-index: 1; display: flex; align-items: center; gap: 6px; max-width: min(46%, 320px); margin: 0; translate: var(--flag-x) -130%; color: var(--ink); font-size: 12px; font-weight: 700; font-stretch: 112%; font-variant-numeric: tabular-nums; white-space: nowrap; pointer-events: none; } +.sheet-flag::before { content: ''; flex: none; width: 22px; height: 12px; translate: 0 7px; border-top: 1px solid currentColor; border-left: 1px solid currentColor; transform: skewX(-35deg); transform-origin: bottom left; } +.sheet-flag span { overflow: hidden; text-overflow: ellipsis; padding: 1px 4px; background: color-mix(in srgb, var(--surface) 88%, transparent); } +.sheet-flag[data-side=left] { --flag-x: calc(-100% - 8px); flex-direction: row-reverse; } +.sheet-flag[data-side=left]::before { border-left: 0; border-right: 1px solid currentColor; transform: skewX(35deg); transform-origin: bottom right; } +.sheet-flag[data-kind=running] { color: var(--accent-text); } + +@media (prefers-reduced-motion: reduce) { + .tach-sweep, .tach-needle { transition: none; } +} diff --git a/src/browser/dyno-chart.js b/src/browser/dyno-chart.js index 775b840..601d02f 100644 --- a/src/browser/dyno-chart.js +++ b/src/browser/dyno-chart.js @@ -83,9 +83,20 @@ export function pullSheet(curve, { ghost, href } = {}) { export function sheetHead(curve, ghost) { let s = scale(curve, usable(curve, ghost)); let last = curve.points.at(-1); - return Object.freeze({ left: (s.x(last?.x ?? 0) / width) * 100, top: (s.y(last?.y ?? 0) / height) * 100 }); + let left = (s.x(last?.x ?? 0) / width) * 100; + // Notes near the right edge print to the left of the point so they never clip. + return Object.freeze({ left, top: (s.y(last?.y ?? 0) / height) * 100, side: left > 62 ? 'left' : 'right' }); } +export let testCount = (count) => `${count} ${count === 1 ? 'test' : 'tests'}`; + +// The finished note, printed like a dyno sheet's peak: "13 tests @ 1.90 s". +export let sheetNote = (curve) => `${testCount(curve.total)} @ ${timingDuration(curve.time)}`; + +// The x-axis label. The axis spans the longer of this pull and its ghost. +export let sheetSpan = (curve, ghost) => + curve.time ? `${timingDuration(Math.max(curve.time, usable(curve, ghost)?.time ?? 0))} of test time` : 'test time'; + /** * A small tachometer for the run title. The needle sweeps from 0 to all tests finished. */ diff --git a/src/browser/host.css b/src/browser/host.css index aa6b6e8..c0aefdf 100644 --- a/src/browser/host.css +++ b/src/browser/host.css @@ -1,17 +1,13 @@ @font-face { font-family: 'Archivo Dyno'; font-weight: 100 900; font-stretch: 62% 125%; font-display: swap; src: url('/__dyno__/archivo.woff2') format('woff2'); } -:root { color-scheme: dark; --bg: #141f2a; --line: #2a3d4f; --ink: #e9eff4; --muted: #90a5b7; --accent: #ffc629; --fail: #ff7a7e; font: 16px/1.55 'Archivo Dyno', system-ui, sans-serif; color: var(--ink); background: var(--bg); } +:root { color-scheme: dark; font: 16px/1.55 var(--sans); color: var(--ink); background: var(--bg); } *, *::before, *::after { box-sizing: border-box; } body { margin: 0; min-height: 100dvh; display: grid; place-items: center; background: radial-gradient(circle at 50% 120%, #1d3042, var(--bg) 60%); } .control-room { width: min(34rem, 100%); padding: 32px 24px; } -.wordmark { display: inline-flex; align-items: baseline; font-size: 44px; line-height: 1; font-weight: 860; font-stretch: 125%; letter-spacing: -.035em; transform: skewX(-9deg); } -.roller { width: .8em; height: .8em; margin-left: .04em; align-self: center; translate: 0 .1em; overflow: visible; } -.roller-drum { fill: none; stroke: var(--accent); stroke-width: 5.5; } -.roller-knurl { stroke: var(--accent); stroke-width: 3; stroke-linecap: round; transform-origin: 20px 20px; animation: spin 1.4s linear infinite; } -.roller-hub { fill: var(--accent); } -@keyframes spin { to { rotate: 1turn; } } +.wordmark { font-size: 44px; } +.roller-knurl { animation: spin 1.4s linear infinite; } h1 { margin: 28px 0 6px; font-size: 22px; font-weight: 720; font-stretch: 112%; } p { margin: 0; color: var(--muted); } -button { margin-top: 22px; min-height: 40px; padding: 8px 18px; font: inherit; font-weight: 720; color: #1d1600; background: var(--accent); border: 0; border-radius: 6px; cursor: pointer; } +button { margin-top: 22px; min-height: 40px; padding: 8px 18px; font: inherit; font-weight: 720; color: var(--on-accent); background: var(--accent); border: 0; border-radius: 6px; cursor: pointer; } button:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; } #execution > iframe { display: none; } [hidden] { display: none !important; } diff --git a/src/browser/reporter.css b/src/browser/reporter.css index 71b869c..2543a49 100644 --- a/src/browser/reporter.css +++ b/src/browser/reporter.css @@ -1,5 +1,5 @@ /* Dyno: the reporter is a dyno sheet. One pull per run, plotted as it happens. - Tokens first; every color below comes from here. */ + Tokens, the wordmark, the tach, and the sheet live in brand.css, which loads first. */ @font-face { font-family: 'Archivo Dyno'; font-style: normal; @@ -9,51 +9,14 @@ src: url('/__dyno__/archivo.woff2') format('woff2'); } :root { - --roller: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 40'%3E%3Ccircle cx='20' cy='20' r='15.5' fill='none' stroke='%23000' stroke-width='5.5'/%3E%3Cpath d='M20 4.5v5M20 30.5v5M4.5 20h5M30.5 20h5M9 9l3.5 3.5M27.5 27.5L31 31M31 9l-3.5 3.5M12.5 27.5L9 31' stroke='%23000' stroke-width='3' stroke-linecap='round'/%3E%3Ccircle cx='20' cy='20' r='4'/%3E%3C/svg%3E"); color-scheme: dark light; - --bg: #141f2a; - --surface: #1a2835; - --raised: #223444; - --line: #2a3d4f; - --grid: #20303f; - --ink: #e9eff4; - --muted: #90a5b7; - --faint: #62778b; - --trace: #63a9ff; - --accent: #ffc629; - --on-accent: #1d1600; - --accent-text: #ffc629; - --mark: #ffc629; --desk-height: 60px; - --pass: #4fd08a; - --fail: #ff5c61; - --fail-tint: #ff5c6114; - --hover: #63a9ff0d; --shadow: 0 18px 40px #0007; - --sans: 'Archivo Dyno', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; - --mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font: 400 14px/1.5 var(--sans); font-stretch: 100%; } @media (prefers-color-scheme: light) { - :root { - --bg: #edf1f5; - --surface: #ffffff; - --raised: #f5f8fa; - --line: #ccd6e0; - --grid: #e1e8ef; - --ink: #0f1f2e; - --muted: #536a7e; - --faint: #8496a8; - --trace: #1a5bc4; - --accent-text: #845e00; - --mark: #e0a400; - --pass: #11804a; - --fail: #c8192e; - --fail-tint: #c8192e0d; - --hover: #1a5bc40a; - --shadow: 0 18px 40px #0f1f2e24; - } + :root { --shadow: 0 18px 40px #0f1f2e24; } } * { box-sizing: border-box; } @@ -76,7 +39,7 @@ button { cursor: pointer; font-weight: 600; white-space: nowrap; } button:hover:not(:disabled) { border-color: var(--faint); } button:disabled { opacity: .42; cursor: default; } button.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); font-weight: 720; font-stretch: 108%; } -button.primary:hover:not(:disabled) { background: #ffd457; border-color: #ffd457; } +button.primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); } button.primary:disabled { background: transparent; border-color: var(--line); color: var(--muted); } button.quiet { background: transparent; } button.stop:not(:disabled) { color: var(--fail); border-color: color-mix(in srgb, var(--fail) 45%, transparent); } @@ -87,13 +50,8 @@ select { padding-right: 8px; } /* Control desk */ .desk { position: sticky; top: 0; z-index: 4; height: var(--desk-height); padding: 0 28px; display: flex; align-items: center; justify-content: space-between; gap: 16px; background: color-mix(in srgb, var(--bg) 92%, transparent); backdrop-filter: blur(10px); border-bottom: 1px solid var(--line); } .brand { display: inline-flex; align-items: center; text-decoration: none; padding: 4px 2px; } -.wordmark { display: inline-flex; align-items: baseline; font-size: 27px; line-height: 1; font-weight: 860; font-stretch: 125%; letter-spacing: -.035em; transform: skewX(-9deg); } -.roller { width: .8em; height: .8em; margin-left: .04em; align-self: center; translate: 0 .1em; overflow: visible; } -.roller-drum { fill: none; stroke: var(--mark); stroke-width: 5.5; } -.roller-knurl { stroke: var(--mark); stroke-width: 3; stroke-linecap: round; transform-origin: 20px 20px; } -.roller-hub { fill: var(--mark); } +.wordmark { font-size: 27px; } :root[data-phase=running] .roller-knurl, :root[data-phase=discovering] .roller-knurl, :root[data-phase=resuming] .roller-knurl, :root[data-phase=pausing] .roller-knurl { animation: spin .7s linear infinite; } -@keyframes spin { to { rotate: 1turn; } } .run-actions { display: flex; align-items: center; gap: 8px; } .run-options { position: relative; } .run-options > summary { display: grid; place-items: center; width: 34px; height: 34px; list-style: none; cursor: pointer; border-radius: 6px; color: var(--muted); } @@ -118,7 +76,7 @@ select { padding-right: 8px; } /* Paused: the car is stopped on the rollers. The only place hazard stripes appear. */ .pause-banner { position: relative; display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 18px 18px 14px; margin-bottom: 28px; background: color-mix(in srgb, var(--accent) 8%, var(--surface)); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); border-radius: 8px; overflow: hidden; font-size: 14px; font-weight: 560; } -.pause-banner::before { content: ''; position: absolute; inset: 0 0 auto; height: 5px; background: repeating-linear-gradient(-45deg, var(--accent) 0 9px, var(--on-accent) 9px 18px); } +.pause-banner::before { content: ''; position: absolute; inset: 0 0 auto; height: 5px; background: var(--hazard); } .pause-banner a { margin-left: 6px; color: var(--accent-text); font-weight: 650; } .pause-banner button { background: var(--accent); border-color: var(--accent); color: var(--on-accent); font-weight: 720; } @@ -126,46 +84,19 @@ select { padding-right: 8px; } .pull { margin-bottom: 20px; } .pull-title { display: flex; align-items: center; gap: 14px; } /* The tachometer: needle sweeps to all tests finished. Its arc takes the run's status color. */ -.tach { width: 60px; height: 34px; flex: none; overflow: visible; --tone: var(--faint); } -.tach path { fill: none; vector-effect: non-scaling-stroke; } -.tach-ticks { stroke: var(--muted); stroke-width: 1.6; stroke-linecap: round; } -.tach-track { stroke: var(--grid); stroke-width: 5; } -.tach-sweep { stroke: var(--tone); stroke-width: 5; stroke-dasharray: var(--progress, 0) 1; transition: stroke-dasharray .35s ease-out; } -.tach-needle { stroke: var(--ink); stroke-width: 2.4; stroke-linecap: round; transform-origin: 30px 31px; rotate: calc(-90deg + var(--progress, 0) * 180deg); transition: rotate .35s cubic-bezier(.3, 1.5, .5, 1); } -.tach-hub { fill: var(--ink); } -.tach[data-phase=running], .tach[data-phase=discovering], .tach[data-phase=pausing], .tach[data-phase=resuming], .tach[data-phase=paused], .tach[data-phase=debugging] { --tone: var(--accent); } -.tach[data-phase=completed] { --tone: var(--pass); } -.tach[data-phase=completed][data-failed=true], .tach[data-phase=stopped][data-failed=true], .tach[data-phase=error] { --tone: var(--fail); } +.tach { width: 60px; height: 34px; } #status { font-size: clamp(26px, 3.2vw, 38px); } #run-meta { margin: 8px 0 0 74px; color: var(--muted); overflow-wrap: anywhere; } /* The sheet */ .sheet { display: grid; grid-template-columns: minmax(0, 1fr) 236px; margin-bottom: 24px; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; } .sheet-chart { margin: 0; padding: 20px 22px 12px; min-width: 0; } -.sheet-frame { position: relative; height: clamp(140px, 19vh, 236px); margin: 0 0 26px 0; border-left: 1px solid var(--faint); border-bottom: 1px solid var(--faint); } -.sheet-canvas, .sheet-plot { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; } -.sheet-plot path { vector-effect: non-scaling-stroke; fill: none; } -.sheet-plot .sheet-area { fill: var(--trace); opacity: .07; stroke: none; } -.sheet-grid { stroke: var(--grid); stroke-width: 1; } -.sheet-ceiling { stroke: var(--faint); stroke-width: 1; stroke-dasharray: 3 5; } -.sheet-ghost { stroke: var(--faint); stroke-width: 1.5; stroke-dasharray: 2 5; stroke-linejoin: round; } -.sheet-trace { stroke: var(--trace); stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; } -.sheet-point { stroke: var(--trace); stroke-width: 6; stroke-linecap: round; } -.sheet-point[data-status=failed] { stroke: var(--fail); stroke-width: 11; } -.sheet-point[data-status=skipped], .sheet-point[data-status=stopped] { stroke: var(--faint); } +.sheet-frame { height: clamp(140px, 19vh, 236px); margin: 0 0 26px 0; } .sheet-plot a:hover .sheet-point { stroke-width: 13; } .sheet-head { stroke: var(--accent); stroke-width: 12; stroke-linecap: round; animation: throb 1s ease-in-out infinite alternate; } @keyframes throb { from { stroke-width: 8; } to { stroke-width: 14; } } -.sheet-axis { position: absolute; color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; } -.sheet-axis-y { left: 8px; top: -20px; } -.sheet-axis-x { right: 0; bottom: -22px; } .sheet-frame::after { content: '0'; position: absolute; left: -2px; bottom: -22px; color: var(--muted); font-size: 11px; } .sheet-chart figcaption { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 20px; color: var(--muted); font-size: 12px; } -.sheet-legend { display: flex; gap: 18px; } -.legend-trace, .legend-ghost { display: inline-flex; align-items: center; gap: 8px; } -.legend-trace::before, .legend-ghost::before { content: ''; width: 18px; } -.legend-trace::before { border-top: 2.5px solid var(--trace); } -.legend-ghost::before { border-top: 2px dotted var(--faint); } .readouts { margin: 0; display: flex; flex-direction: column; border-left: 1px solid var(--line); } .readout { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex: 1; padding: 6px 20px; border-bottom: 1px solid var(--line); } .readout:last-child { border-bottom: 0; } @@ -380,14 +311,6 @@ select { padding-right: 8px; } *, *::before, *::after { animation: none !important; scroll-behavior: auto !important; } } -/* Sheet notes ride the curve like the peak note on a printed dyno sheet: a leader line, then plain text. */ -.sheet-flag { position: absolute; z-index: 1; display: flex; align-items: center; gap: 6px; max-width: min(46%, 320px); margin: 0; translate: 8px -130%; font-size: 12px; font-weight: 700; font-stretch: 112%; white-space: nowrap; pointer-events: none; } -.sheet-flag::before { content: ''; flex: none; width: 22px; height: 12px; translate: 0 7px; border-top: 1px solid currentColor; border-left: 1px solid currentColor; transform: skewX(-35deg); transform-origin: bottom left; } -.sheet-flag span { overflow: hidden; text-overflow: ellipsis; padding: 1px 4px; background: color-mix(in srgb, var(--surface) 88%, transparent); } -.sheet-flag[data-side=left] { flex-direction: row-reverse; translate: calc(-100% - 8px) -130%; } -.sheet-flag[data-side=left]::before { border-left: 0; border-right: 1px solid currentColor; transform: skewX(35deg); transform-origin: bottom right; } -.sheet-flag[data-kind=running] { color: var(--accent-text); } -.sheet-flag[data-kind=finished] { color: var(--ink); font-variant-numeric: tabular-nums; } #run-seed { margin: 0 0 12px; color: var(--ink); font-family: var(--mono); font-size: 12px; } /* Keep scrolled-to targets clear of the sticky control desk. */ html { scroll-padding-top: calc(var(--desk-height) + 12px); } diff --git a/src/browser/reporter.js b/src/browser/reporter.js index f5b023e..76a890a 100644 --- a/src/browser/reporter.js +++ b/src/browser/reporter.js @@ -1,5 +1,5 @@ import { slowestTests } from './timings.js'; -import { pullCurve, pullSheet, ghostCurve, pace, sheetSummary, sheetHead, tachometer, usable } from './dyno-chart.js'; +import { pullCurve, pullSheet, ghostCurve, pace, sheetSummary, sheetHead, tachometer, usable, testCount, sheetNote, sheetSpan } from './dyno-chart.js'; import { timingDuration, timingLabels, timingPhases, timingOperations, timingText, failurePlace } from './timing-format.js'; export function duration(value) { @@ -400,18 +400,18 @@ export function createReporter(actions) { let shownGhost = usable(curve, ghost); write(get('ghost-legend'), shownGhost ? `Last pull, ${duration(shownGhost.time)}` : ''); get('ghost-legend').hidden = !shownGhost; - write(get('sheet-total'), curve.total ? `${curve.total} ${curve.total === 1 ? 'test' : 'tests'}` : ''); - write(get('sheet-time'), curve.time ? `${duration(Math.max(curve.time, shownGhost?.time ?? 0))} of test time` : 'test time'); + write(get('sheet-total'), curve.total ? testCount(curve.total) : ''); + write(get('sheet-time'), sheetSpan(curve, shownGhost)); write(get('sheet-summary'), sheetSummary(curve)); let average = pace(curve); write(get('run-pace'), average === undefined ? '—' : duration(average)); let flag = get('sheet-flag'); let head = sheetHead(curve, shownGhost); let finishedPull = curve.total && curve.done === curve.total && !curve.running; - let label = curve.running ? curve.running.name : finishedPull ? `${curve.total} ${curve.total === 1 ? "test" : "tests"} @ ${duration(curve.time)}` : ''; + let label = curve.running ? curve.running.name : finishedPull ? sheetNote(curve) : ''; flag.hidden = !label; flag.dataset.kind = curve.running ? 'running' : 'finished'; - flag.dataset.side = head.left > 62 ? 'left' : 'right'; + flag.dataset.side = head.side; flag.style.left = `${head.left}%`; flag.style.top = `${head.top}%`; write(get('sheet-flag-text'), label); diff --git a/src/report-format.js b/src/report-format.js index 44c3185..9782735 100644 --- a/src/report-format.js +++ b/src/report-format.js @@ -1,7 +1,7 @@ import { testTitle } from './suite.js'; import { timingPhases, timingOperations, timingDuration, timingLabels, failurePlace } from './browser/timing-format.js'; import { slowestTests } from './browser/timings.js'; -import { pullCurve, pullSheet, pace, tachometer } from './browser/dyno-chart.js'; +import { pullCurve, pullSheet, pace, tachometer, testCount, sheetSpan } from './browser/dyno-chart.js'; import { escapeMarkup, wordmark } from './browser/markup.js'; export { escapeMarkup }; @@ -106,7 +106,7 @@ export function htmlReport(report, css, { session = false } = {}) {

Saved report ${x(report.runId)}${report.startedAt ? `, started ${x(report.startedAt)}` : ''}

${report.schedule?.seed != null ? `

Seed: ${x(report.schedule.seed)}

` : ''} ${curve.total ? `
-
${pullSheet(curve, { href: (point) => `#test-${point.index}` })}
${curve.total} ${curve.total === 1 ? 'test' : 'tests'}${x(timingDuration(curve.time))} of test time
+
${pullSheet(curve, { href: (point) => `#test-${point.index}` })}
${testCount(curve.total)}${x(sheetSpan(curve))}
This pullSteep is fast. Flat stretches are slow tests.
${readout('Passed', report.summary.passed, 'passed')}
Failed
${report.summary.failed}
${readout('Skipped', report.summary.skipped)}${readout('Not run', report.summary.notRun)}${readout('Run time', x(timingDuration(report.summary.duration)))}${readout('Per test', average === undefined ? '—' : x(timingDuration(average)))}
` : ''} diff --git a/src/reports.js b/src/reports.js index 5cf89bf..36d0782 100644 --- a/src/reports.js +++ b/src/reports.js @@ -51,7 +51,7 @@ export function createReportWriter(directory, { redact = (value) => value } = {} } if (missing.length) report = missingImages(report, missing); let styles = await Promise.all( - ['reporter.css', 'saved-report.css'].map(file => readFile(new URL(`./browser/${file}`, import.meta.url), 'utf8')) + ['brand.css', 'reporter.css', 'saved-report.css'].map(file => readFile(new URL(`./browser/${file}`, import.meta.url), 'utf8')) ); let font = (await readFile(new URL('./browser/fonts/archivo.woff2', import.meta.url))).toString('base64'); let css = styles.join('\n').replaceAll("url('/__dyno__/archivo.woff2')", `url('data:font/woff2;base64,${font}')`); diff --git a/src/server.js b/src/server.js index b492ef2..c2a43ff 100644 --- a/src/server.js +++ b/src/server.js @@ -12,8 +12,8 @@ let prefix = '/__dyno__/'; let favicon = `data:image/svg+xml,${encodeURIComponent('')}`; let archivo = readFile(new URL('./browser/fonts/archivo.woff2', import.meta.url)); let withWordmark = (html) => html.replace('', wordmark); -let page = (title, body, script, stylesheet = 'runtime.css') => - `${title}${body}`; +let page = (title, body, script, stylesheets = ['runtime.css']) => + `${title}${stylesheets.map((sheet) => ``).join('')}${body}`; async function reporterInput(request, limit = 1_000_000) { let chunks = []; @@ -56,19 +56,19 @@ export async function createGateway( 'Dyno', withWordmark(await readFile(new URL('./browser/host.html', import.meta.url), 'utf8')), 'shell.js', - 'host.css', + ['brand.css', 'host.css'], ); let start = page( 'Dyno', `
${wordmark}

Start a test run

Connecting tests…

`, 'bootstrap.js', - 'host.css', + ['brand.css', 'host.css'], ); let reporter = page( 'Dyno results', withWordmark(await readFile(new URL('./browser/reporter.html', import.meta.url), 'utf8')), 'reporter-client.js', - 'reporter.css', + ['brand.css', 'reporter.css'], ); let documentPolicy = Object.freeze({}); coordinator ??= createCoordinator(config, bundle, { onEvent, onRun, onError, rebuild }); @@ -186,6 +186,7 @@ export async function createGateway( ); if ( [ + 'brand.css', 'reporter.css', 'runtime.css', 'host.css',