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
2 changes: 1 addition & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
- uses: actions/checkout@v7
with:
repository: bloxwap/hig
ref: 19c75619fa5020c015aa639172c83fd02f2bc0a2
ref: 872906a9071469af8714b3b6fb5cc7bd64d2d6f3
path: .hig
sparse-checkout: scripts/drift.mjs
sparse-checkout-cone-mode: false
Expand Down
24 changes: 15 additions & 9 deletions apps/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,21 @@ packed candidates in [`vendor/bloxwap-hig/`](../../vendor/bloxwap-hig/README.md)
`@bloxwap/hig-tokens/css` and `@bloxwap/hig-react/css` supply the `--hig-*` roles and
control recipes, and the install tabs, copy buttons, search states and MDX callouts
use HIG `Tabs`, `InstallCommand`, `IconButton`, `Alert`, `EmptyState` and `Callout`.
`app/global.css` maps Fumadocs onto the HIG roles and keeps only site layout. Never
copy HIG CSS, palettes or fonts into this app. `hig-tokens.mjs --check` (part of
`docs:check`) verifies the vendored hashes, the installed versions and the few
JS-only HIG values `global.css` repeats. CI also runs HIG's scoped drift check
(`hig-drift.json`) and `npm run docs:smoke`, a Playwright pass over install/copy,
keyboard, search, 404 and 390 px navigation against the static export.

Bloxwap Sans and Mono are self-hosted by `@bloxwap/[email protected]`. The social renderer
uses the static Bold/Black instances shipped in `@bloxwap/hig-tokens/fonts`.
`app/global.css` maps Fumadocs onto the HIG roles and keeps only site layout; radius,
motion, type, density and font families are read from the `--hig-radius-*`,
`--hig-motion-*`, `--hig-type-*`, `--hig-density-*` and `--hig-font-*` roles. Never
copy HIG CSS, token values, palettes or fonts into this app. `hig-tokens.mjs --check`
(part of `docs:check`) verifies the vendored hashes, the `file:` specs and overrides,
the installed versions, that every `--hig-*` role `global.css` reads exists in the
installed HIG CSS, and that no token numbers or font bridge come back. CI also runs
HIG's scoped drift check (`hig-drift.json`) and `npm run docs:smoke`, a Playwright pass
over install/copy, keyboard, search, 404, 390 px navigation and touch targets, loaded
Bloxwap faces on HIG controls, and reduced motion against the static export.

Bloxwap Sans and Mono are self-hosted by `@bloxwap/[email protected]`; its Next loader variables
sit on `<html>`, where `--hig-font-sans`/`--hig-font-mono` pick them up. The social
renderer needs static TTFs, which `@bloxwap/font` does not ship, so it reads the Bold/Black
instances from `@bloxwap/hig-native-fonts/fonts` (a build-time dev dependency).
Brand marks in `public/logos/` are supplied artwork, never typeset replacements.
Every MDX page receives a static social card via `app/og/[...slug]/route.tsx`.

Expand Down
104 changes: 43 additions & 61 deletions apps/docs/app/global.css

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions apps/docs/components/callout.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { ReactNode } from 'react';
import { Callout as HIGCallout } from '@bloxwap/hig-react/status';

const severities = { info: 'info', idea: 'info', warn: 'warning', warning: 'warning', error: 'error', success: 'success' } as const;
const titles = { info: 'Note', warning: 'Important', error: 'Error', success: 'Success' } as const;
const severities = { info: 'note', idea: 'tip', warn: 'warning', warning: 'warning', error: 'error', success: 'success' } as const;
const titles = { note: 'Note', tip: 'Tip', warning: 'Important', error: 'Error', success: 'Success' } as const;

/** Fumadocs' MDX `<Callout type title>` API, rendered with the HIG callout. Static docs content is not a live region. */
/** Fumadocs' MDX `<Callout type title>` API, rendered with the HIG callout (note/tip severities, inline-node titles). Static docs content is not a live region. */
export function Callout({ type = 'info', title, children }: { type?: keyof typeof severities; title?: ReactNode; children?: ReactNode }) {
const severity = severities[type] ?? 'info';
const severity = severities[type] ?? 'note';
return <div className="docs-callout not-prose">
<HIGCallout title={typeof title === 'string' ? title : titles[severity]} severity={severity} announce={false}>{children}</HIGCallout>
<HIGCallout title={title ?? titles[severity]} severity={severity} announce={false}>{children}</HIGCallout>
</div>;
}
2 changes: 1 addition & 1 deletion apps/docs/components/install-command.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { Tabs } from '@bloxwap/hig-react/navigation';
import { InstallCommand as HIGInstallCommand } from '@bloxwap/hig-react/recipes';
import { InstallCommand as HIGInstallCommand } from '@bloxwap/hig-react/recipes/install-command';

const managers = [
{ name: 'npm', command: 'npm install -g @bloxwap/agent-cli' },
Expand Down
7 changes: 4 additions & 3 deletions apps/docs/lib/og-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ const { colors } = tokens;
const ink = colors.text.srgb;
const muted = colors.muted.srgb;
const bar: [color: string, share: number][] = [[colors.action.srgb, 62], [colors.info.srgb, 14], ['#b300ff', 10], ['#ff479c', 8], ['#fffb38', 6]];
// Approved static Bloxwap Sans instances (derived from @bloxwap/font 0.1.2, OFL) shipped by the HIG tokens package.
// Satori reads static TTFs, which @bloxwap/font (WOFF2 only) does not ship. These approved static Bloxwap Sans instances
// (derived from @bloxwap/font 0.1.2, OFL) come from @bloxwap/hig-native-fonts, a build-time dependency of this renderer.
const hig = createRequire(join(process.cwd(), 'package.json'));
const assets = Promise.all([
readFile(hig.resolve('@bloxwap/hig-tokens/fonts/BloxwapSans-Bold.ttf')),
readFile(hig.resolve('@bloxwap/hig-tokens/fonts/BloxwapSans-Black.ttf')),
readFile(hig.resolve('@bloxwap/hig-native-fonts/fonts/BloxwapSans-Bold.ttf')),
readFile(hig.resolve('@bloxwap/hig-native-fonts/fonts/BloxwapSans-Black.ttf')),
readFile(join(process.cwd(), '../../packages/agent-cli/package.json'), 'utf8'),
]);

Expand Down
9 changes: 4 additions & 5 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,9 @@
},
"dependencies": {
"@bloxwap/font": "0.1.2",
"@bloxwap/hig-contracts": "file:../../vendor/bloxwap-hig/bloxwap-hig-contracts-0.1.0-candidate.sha19c75619.tgz",
"@bloxwap/hig-react": "file:../../vendor/bloxwap-hig/bloxwap-hig-react-0.1.0-candidate.sha19c75619.tgz",
"@bloxwap/hig-tokens": "file:../../vendor/bloxwap-hig/bloxwap-hig-tokens-0.1.0-candidate.sha19c75619.tgz",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-tooltip": "1.2.8",
"@bloxwap/hig-contracts": "file:../../vendor/bloxwap-hig/bloxwap-hig-contracts-0.1.0-candidate.sha872906a9.tgz",
"@bloxwap/hig-react": "file:../../vendor/bloxwap-hig/bloxwap-hig-react-0.1.0-candidate.sha872906a9.tgz",
"@bloxwap/hig-tokens": "file:../../vendor/bloxwap-hig/bloxwap-hig-tokens-0.1.0-candidate.sha872906a9.tgz",
"fumadocs-core": "16.15.14",
"fumadocs-mdx": "15.4.5",
"fumadocs-ui": "16.15.14",
Expand All @@ -30,6 +28,7 @@
"react-dom": "^19.2.0"
},
"devDependencies": {
"@bloxwap/hig-native-fonts": "file:../../vendor/bloxwap-hig/bloxwap-hig-native-fonts-0.1.0-candidate.sha872906a9.tgz",
"@tailwindcss/postcss": "^4.2.0",
"@types/mdx": "^2.0.13",
"@types/node": "^24.13.6",
Expand Down
47 changes: 22 additions & 25 deletions apps/docs/scripts/hig-tokens.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Verifies the HIG package install: vendored candidate tarballs match their recorded hashes, the docs depend on
// exactly those files, the installed packages are the same build, and the few JS-only HIG numbers that
// app/global.css copies (radius, motion, type, density) still equal the installed @bloxwap/hig-tokens.
// Verifies the HIG package install: vendored candidate tarballs match their recorded hashes, the docs and the root
// overrides depend on exactly those files, the installed packages are the same build, and app/global.css reads HIG
// values through --hig-* roles instead of copying token numbers or bridging font families.
import { readFile, stat } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import assert from 'node:assert/strict';
Expand All @@ -11,6 +11,7 @@ const docs = new URL('../', import.meta.url);
const vendor = new URL('vendor/bloxwap-hig/', root);
const provenance = JSON.parse(await readFile(new URL('provenance.json', vendor), 'utf8'));
const manifest = JSON.parse(await readFile(new URL('package.json', docs), 'utf8'));
const workspace = JSON.parse(await readFile(new URL('package.json', root), 'utf8'));
/** npm workspaces install a package in apps/docs/node_modules or hoist it to the root. */
async function installedManifest(name) {
for (const base of [docs, root]) {
Expand All @@ -22,38 +23,34 @@ async function installedManifest(name) {
for (const { package: name, file, sha256 } of provenance.artifacts) {
const digest = createHash('sha256').update(await readFile(new URL(file, vendor))).digest('hex');
assert.equal(digest, sha256, `Vendored HIG tarball changed: ${file}`);
assert.equal(manifest.dependencies[name], `file:../../vendor/bloxwap-hig/${file}`, `${name} must install from vendor/bloxwap-hig/${file}`);
const spec = manifest.dependencies[name] ?? manifest.devDependencies?.[name];
assert.equal(spec, `file:../../vendor/bloxwap-hig/${file}`, `${name} must install from vendor/bloxwap-hig/${file}`);
if (name in (workspace.overrides ?? {})) {
assert.equal(workspace.overrides[name], `file:./vendor/bloxwap-hig/${file}`, `root override for ${name} must point at vendor/bloxwap-hig/${file}`);
}
const installed = await installedManifest(name);
assert.equal(installed.version, provenance.version, `${name}: installed ${installed.version}, vendored ${provenance.version}; run npm install`);
}
assert.equal(tokens.version, provenance.version.split('-')[0]);

const css = await readFile(new URL('app/global.css', docs), 'utf8');
const px = (value) => `${value}px`;
const rem = (value) => `${value / 16}rem`;
const expected = {
'--docs-radius': px(tokens.radius.xl),
'--docs-radius-pill': px(tokens.radius.pill),
'--docs-duration': `${tokens.motion.fast}ms`,
'--docs-control-compact': px(tokens.density.compact.control),
'--docs-control-touch': px(tokens.density.comfortable.control),
'--docs-type-caption': rem(tokens.type.caption.size),
'--docs-type-body': rem(tokens.type.body.size),
'--docs-type-body-line': rem(tokens.type.body.lineHeight),
'--docs-type-title': rem(tokens.type.title.size),
'--docs-type-title-line': rem(tokens.type.title.lineHeight),
'--docs-type-display': rem(tokens.type.display.size),
'--docs-type-weight': String(tokens.type.title.weight),
};
assert.equal(tokens.type.display.weight, tokens.type.title.weight, '--docs-type-weight serves both title and display');
for (const [name, value] of Object.entries(expected)) {
const match = css.match(new RegExp(`^\\s*${name}:\\s*([^;]+);`, 'm'));
assert.equal(match?.[1].trim(), value, `app/global.css ${name} must equal @bloxwap/hig-tokens (${value})`);
// Radius, motion, type, density and font families are CSS roles in @bloxwap/hig-tokens/css; a host copy would drift.
const copied = css.match(/^\s*--docs-(?:radius|duration|ease|control|type)[\w-]*\s*:/m);
assert.ok(!copied, `app/global.css must use --hig-radius/motion/type/density roles, not copy ${copied?.[0].trim()}`);
assert.ok(!/var\(--font-bloxwap-(?:sans|mono)\)/.test(css), 'app/global.css must read fonts through --hig-font-sans/--hig-font-mono');
assert.ok(!/\.hig-(?:button|number)[^{]*\{[^}]*font-family/.test(css), 'HIG controls resolve the font roles themselves; no font-family bridge');
const roles = ['--hig-radius-xl', '--hig-motion-duration-fast', '--hig-type-body-size', '--hig-density-compact-control', '--hig-font-sans'];
const tokenCss = await readFile(new URL(import.meta.resolve('@bloxwap/hig-tokens/css')), 'utf8');
for (const role of roles) assert.ok(tokenCss.includes(`${role}:`), `installed @bloxwap/hig-tokens/css must define ${role}`);
// Every role the host reads must exist in the installed HIG CSS, so a renamed or misspelled role fails here, not visually.
const reactCss = await readFile(new URL(import.meta.resolve('@bloxwap/hig-react/css')), 'utf8');
for (const [, role] of css.matchAll(/var\((--hig-[\w-]+)/g)) {
assert.ok(tokenCss.includes(`${role}:`) || reactCss.includes(`${role}:`), `app/global.css reads ${role}, which HIG does not define`);
}

// The copied HIG folder, alias layer and committed fonts must not come back.
for (const path of ['vendor/hig', 'styles', 'fonts']) {
await assert.rejects(stat(new URL(path, docs)), `apps/docs/${path} must not exist; use the HIG packages`);
}
assert.ok(!/--bloxwap-[\w-]+\s*:/.test(css), 'app/global.css must not define --bloxwap-* aliases; use --hig-* roles');
console.log(`HIG ${provenance.version}: ${provenance.artifacts.length} vendored tarballs, installed packages and ${Object.keys(expected).length} host token values verified.`);
console.log(`HIG ${provenance.version}: ${provenance.artifacts.length} vendored tarballs, installed packages and HIG role usage verified.`);
40 changes: 40 additions & 0 deletions apps/docs/scripts/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,26 @@ try {
assert.deepEqual(errors, []);
});

await check('HIG controls and code render loaded Bloxwap faces through --hig-font-* (no host font bridge)', async () => {
await page.evaluate(() => document.fonts.ready);
const faces = await page.evaluate(() => {
const loaded = new Set([...document.fonts].filter((face) => face.status === 'loaded').map((face) => face.family.replace(/["']/g, '')));
return [
['Get started', document.querySelector('.hero-actions .hig-button')],
['install tab', document.querySelector('.package-install [role=tab]')],
['code window', document.querySelector('.code-window pre')],
].map(([name, el]) => {
const family = el ? getComputedStyle(el).fontFamily.split(',')[0].trim().replace(/["']/g, '') : '';
return { name, family, loaded: loaded.has(family), check: el ? document.fonts.check(`600 16px "${family}"`) : false };
});
});
for (const { name, family, loaded, check } of faces) {
assert.ok(/bloxwap/i.test(family) && loaded && check, `${name} renders ${family || 'nothing'} (loaded ${loaded}, check ${check})`);
}
const [sans, , mono] = faces.map((face) => face.family);
assert.notEqual(sans, mono, 'code uses Bloxwap Mono, controls use Bloxwap Sans');
});

await check('install tabs: arrow/Home/End keys move selection and focus', async () => {
const tabs = page.getByRole('tablist', { name: 'Package manager' });
const npm = tabs.getByRole('tab', { name: 'npm' });
Expand Down Expand Up @@ -139,6 +159,13 @@ try {
await noHorizontalScroll(phone);
await capture(phone, 'home-mobile');
});
await check('390 px touch: compact HIG copy controls keep a 48 px target', async () => {
await phone.goto(url('/'));
for (const name of ['Copy workflow commands', /copy install command/i]) {
const box = await phone.getByRole('button', { name }).filter({ visible: true }).first().boundingBox();
assert.ok(box && box.height >= 48 && box.width >= 48, `${name}: ${box?.width}x${box?.height}`);
}
});
await check('390 px sidebar navigation reaches a deep link', async () => {
await phone.goto(url('/docs/'));
await noHorizontalScroll(phone);
Expand All @@ -152,6 +179,19 @@ try {
await noHorizontalScroll(phone);
});
await mobile.close();

const reduced = await browser.newContext({ viewport: { width: 1440, height: 900 }, reducedMotion: 'reduce' });
const still = await reduced.newPage();
await check('reduced motion: HIG motion roles drop to 0 ms', async () => {
await still.goto(url('/'));
const motion = await still.evaluate(() => ({
role: getComputedStyle(document.documentElement).getPropertyValue('--hig-motion-duration-fast').trim(),
link: getComputedStyle(document.querySelector('.guide-links a')).transitionDuration,
}));
assert.match(motion.role, /^0m?s$/, `--hig-motion-duration-fast is ${motion.role}`); // the CSS minifier writes 0ms as 0s
assert.match(motion.link, /^0s(, 0s)*$/);
});
await reduced.close();
} finally {
await browser.close();
server.kill();
Expand Down
Loading
Loading