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
1 change: 1 addition & 0 deletions docs-site/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Thumbs.db
# Test artifacts
test-results/
playwright-report/
screenshots/

# Python bytecode from scripts/validate_static.py
__pycache__/
12 changes: 12 additions & 0 deletions docs-site/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,18 @@ The Home hero band carries an ambient constellation drawn on a `<canvas>`: drift
- **Atlas SVG.** `assets/repository-atlas-journey.svg` draws itself on load (hub, project cards, shared-capability links, then each journey line with its arrowhead) through a `<style>` block inside the SVG, so it also animates when embedded with `<img>`. Every rule sits behind `prefers-reduced-motion: no-preference`; the file contains no script.
- **Tests.** `tests/browser/motion.spec.ts` checks the running, paused and static states, the pause control, layout shift, the chunk size budget, and that no keyframe animation runs under reduced motion.

## Design system

Every page is built from a small set of shared primitives in `src/components/`, and every colour comes from a semantic token in `src/styles/tokens.css`. That is what keeps the pages consistent and what makes the dark theme a token remap rather than a second stylesheet.

- **Tokens.** Surfaces and roles (`--color-surface`, `--color-surface-sunken`, `--color-band`, `--color-link`, `--color-accent`, `--color-on-accent`, `--color-focus`, `--focus-ring`, `--color-overlay-hover`), text scale (`--text-xs` to `--text-lg`), `--label-tracking`, `--radius-pill`, `--shadow-card`. The raw palette (`--color-cloud`, `--color-mist`, `--color-midnight`) is for tokens.css only. Three breakpoints: 640, 1024 and 1280 px.
- **Primitives.** `Button` (primary, secondary, ghost; renders a router link, an external link or a button), `Card` (plain, stage accent or tinted; `interactive` adds the hover lift, `reveal` the scroll rise), `SectionHeading` and `Section` (one heading rhythm, left or centred), `ChipNav` (section navigation, jump links, letter navigation), `NumberedSteps` (stage-coloured counters with an optional connector), `StatTile` and `FactStat` (a fact with its source and an optional collapsed caveat), `Sources`, `StageJourney` (the four stages as linked pills), `HeaderGlow` (the static gradient behind every page header).
- **Page headers.** `PageHeader` renders the single dark band per page: `align="center"` on Home, `variant="compact"` on document pages, a `hue` for the glow, and a `backdrop` slot (Home passes the constellation). Every route, including rendered READMEs and the 404 page, uses it, so every page has exactly one h1 inside one dark header.
- **Dark theme.** `tokens.css` sets `color-scheme: light dark` and remaps the semantic tokens under `@media (prefers-color-scheme: dark)`. There is no toggle and nothing is stored: the site follows the operating system. `index.html` carries `color-scheme` and two `theme-color` metas, and `validate_static.py` checks the meta on every prerendered page.
- **Design lint.** `src/styles/designLint.test.ts` scans every CSS module and fails on hex literals, the `white` keyword, `rgba(`, `999px`, raw palette backgrounds and button-like class names outside `Button.module.css`. Run it with `DESIGN_LINT=1 npm test`.
- **Browser structure checks.** `tests/browser/structure.spec.ts` asserts one dark page header with the single h1 on every route, icon plus text in every posture cell, the text alternative behind the Home capability dots, and the doc-page progress bar. The `desktop-dark` and `mobile-dark` Playwright projects run the axe sweep, reflow and structure checks with the OS preference set to dark.
- **Screenshots.** `SCREENSHOTS=1 SHOT_DIR=/tmp/shots npx playwright test screenshots` captures a curated route list in every project (light and dark, desktop and mobile) for review; the files are not a gate and are git-ignored.

## Validation

Before committing:
Expand Down
4 changes: 3 additions & 1 deletion docs-site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="AI Agent Factory: enterprise agentic AI samples for AWS with Amazon Bedrock and Amazon Bedrock AgentCore" />
<meta name="theme-color" content="#0B1220" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#0B1220" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#070B14" />
<title>AI Agent Factory</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230B1220'/%3E%3Cpath d='M8 16h16M16 8v16' stroke='%232563EB' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E" />
<!-- hash-shim -->
Expand Down
4 changes: 2 additions & 2 deletions docs-site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx,mjs --report-unused-disable-directives --max-warnings 0",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tests/tsconfig.json",
"test": "vitest run",
"test:watch": "vitest",
"test": "DESIGN_LINT=1 vitest run",
"test:watch": "DESIGN_LINT=1 vitest",
"validate:static": "python3 scripts/validate_static.py",
"audit": "npm audit --audit-level=high",
"audit:prod": "npm audit --omit=dev --audit-level=high",
Expand Down
12 changes: 12 additions & 0 deletions docs-site/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,17 @@ export default defineConfig({
name: 'desktop',
use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } },
},
// Dark theme sweeps: the same axe, reflow, structure and motion checks with the OS
// preference set to dark, so every route is contrast-checked in both schemes.
{
name: 'desktop-dark',
testMatch: /(a11y|reflow|structure|motion|screenshots)\.spec\.ts$/,
use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 }, colorScheme: 'dark' },
},
{
name: 'mobile-dark',
testMatch: /(a11y|reflow|structure|screenshots)\.spec\.ts$/,
use: { ...devices['Desktop Chrome'], viewport: { width: 390, height: 844 }, colorScheme: 'dark' },
},
],
});
7 changes: 7 additions & 0 deletions docs-site/scripts/validate_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@
TITLE_PATTERN = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
CANONICAL_PATTERN = re.compile(r"<link[^>]+rel=[\"']canonical[\"']", re.IGNORECASE)
OG_TITLE_PATTERN = re.compile(r"<meta[^>]+property=[\"']og:title[\"']", re.IGNORECASE)
COLOR_SCHEME_PATTERN = re.compile(
r"<meta[^>]+name=[\"']color-scheme[\"'][^>]+content=[\"']light dark[\"']"
r"|<meta[^>]+content=[\"']light dark[\"'][^>]+name=[\"']color-scheme[\"']",
re.IGNORECASE,
)
NOINDEX_PATTERN = re.compile(
r"<meta[^>]+name=[\"']robots[\"'][^>]+content=[\"'][^\"']*noindex"
r"|<meta[^>]+content=[\"'][^\"']*noindex[^\"']*[\"'][^>]+name=[\"']robots[\"']",
Expand Down Expand Up @@ -360,6 +365,8 @@ def validate_prerender() -> None:
if not is_stub:
if not OG_TITLE_PATTERN.search(head):
fail(f"{relative} is missing <meta property=\"og:title\">")
if not COLOR_SCHEME_PATTERN.search(head):
fail(f"{relative} is missing <meta name=\"color-scheme\" content=\"light dark\"> (dark theme support)")
continue

# Outside the sitemap only the known redirect stubs may exist, and each must send the
Expand Down
57 changes: 57 additions & 0 deletions docs-site/src/components/BackToTop.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/* Card-like pill fixed bottom-right. Sits under the site header (z 100) and the
reading progress bar (z 101). */
.top {
position: fixed;
right: var(--space-4);
bottom: var(--space-4);
z-index: 99;
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-height: 2.75rem;
min-width: 2.75rem;
padding: 0 var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
background: var(--color-surface);
color: var(--color-text-primary);
box-shadow: var(--shadow-lg);
font-size: var(--text-sm);
font-weight: 600;
line-height: 1.2;
text-decoration: none;
}

.top:hover {
border-color: var(--color-link);
color: var(--color-link-hover);
text-decoration: none;
}

.top:focus-visible {
outline: var(--focus-ring);
outline-offset: 2px;
}

/* Slide in from below the viewport over the first 40vh of scroll. Translate only: the
link keeps full contrast at every scroll position. Outside this support and for
reduced-motion readers the link is simply visible. */
@supports (animation-timeline: scroll()) {
@media (prefers-reduced-motion: no-preference) {
.top {
animation: back-to-top-rise linear both;
animation-timeline: scroll(root block);
animation-range: 0 40vh;
}
}
}

@keyframes back-to-top-rise {
from {
translate: 0 calc(100% + var(--space-6));
}

to {
translate: 0 0;
}
}
12 changes: 12 additions & 0 deletions docs-site/src/components/BackToTop.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { BackToTop } from './BackToTop';

describe('BackToTop', () => {
it('renders a visibly labelled link to the main landmark', () => {
render(<BackToTop />);
const link = screen.getByRole('link', { name: 'Back to top' });
expect(link).toHaveAttribute('href', '#main-content');
expect(link.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
});
});
18 changes: 18 additions & 0 deletions docs-site/src/components/BackToTop.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { ArrowUp } from 'lucide-react';
import styles from './BackToTop.module.css';

/**
* "Back to top" link for long documents, fixed bottom-right. It targets the main
* landmark (`#main-content` in Layout), so activating it both scrolls to the top and
* moves focus to the start of the page. In browsers with scroll-driven animations it
* slides in from below the viewport during the first 40vh of scrolling (translate only,
* never opacity, so its contrast is constant); elsewhere it is simply visible.
*/
export function BackToTop() {
return (
<a href="#main-content" className={styles.top} data-back-to-top>
<ArrowUp size={16} aria-hidden="true" />
Back to top
</a>
);
}
12 changes: 10 additions & 2 deletions docs-site/src/components/Breadcrumbs.module.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/* Breadcrumb trail. It renders inside the page header's `.on-dark` band, where
`--color-text-primary` and `--color-text-secondary` are already remapped to the
light set, so only semantic tokens are used here and nothing is hard-coded. */
.nav {
font-size: 0.875rem;
margin-bottom: var(--space-4);
font-size: var(--text-sm);
}

.list {
Expand All @@ -20,6 +22,7 @@
.link {
color: var(--color-text-secondary);
text-decoration: underline;
text-decoration-thickness: from-font;
text-underline-offset: 0.15em;
display: inline-flex;
align-items: center;
Expand All @@ -30,6 +33,11 @@
color: var(--color-text-primary);
}

.link:focus-visible {
outline: var(--focus-ring);
outline-offset: 2px;
}

.current {
color: var(--color-text-primary);
font-weight: 500;
Expand Down
111 changes: 111 additions & 0 deletions docs-site/src/components/Button.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/* The site's single button style. Variants and sizes are data attributes so tests
and pages never depend on hashed class names. Colours are tokens only, which is
what makes the same button work on light surfaces, inside `.on-dark` bands and
in the dark theme. */
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: 2.75rem;
padding: 0 var(--space-5);
border: 1px solid transparent;
border-radius: var(--radius-md);
font: inherit;
font-size: var(--text-base);
font-weight: 600;
line-height: 1.2;
text-decoration: none;
cursor: pointer;
transition:
background-color var(--transition-fast),
color var(--transition-fast),
border-color var(--transition-fast),
translate var(--transition-fast);
}

.button:hover {
text-decoration: none;
}

.button:focus-visible {
outline: var(--focus-ring);
outline-offset: 2px;
}

.button[data-size='sm'] {
min-height: 2rem;
padding: 0 var(--space-3);
font-size: var(--text-sm);
}

.button[data-size='lg'] {
min-height: 3rem;
padding: 0 var(--space-6);
font-size: 1.0625rem;
}

/* Primary: accent fill on every surface. */
.button[data-variant='primary'] {
background: var(--color-accent);
color: var(--color-on-accent);
}

.button[data-variant='primary']:hover {
background: var(--color-blue-hover);
color: var(--color-on-accent);
}

/* Secondary: outline in the current text colour, so it adapts to dark bands by inheritance. */
.button[data-variant='secondary'] {
background: transparent;
color: var(--color-text-primary);
border-color: currentColor;
}

.button[data-variant='secondary']:hover {
background: var(--color-overlay-hover);
color: var(--color-text-primary);
}

/* Ghost: link-coloured text, no border, quiet hover. */
.button[data-variant='ghost'] {
background: transparent;
color: var(--color-link);
padding-inline: var(--space-3);
}

.button[data-variant='ghost']:hover {
background: var(--color-overlay-hover);
color: var(--color-link-hover);
text-decoration: underline;
}

.icon {
display: inline-flex;
flex-shrink: 0;
}

.icon > svg {
display: block;
}

.label {
min-width: 0;
}

/* Micro-interactions: pointer devices only, never under reduced motion. */
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
.iconEnd {
transition: translate var(--transition-fast);
}

.button:hover .iconEnd,
.button:focus-visible .iconEnd {
translate: 2px 0;
}

.button:active {
translate: 0 1px;
}
}
55 changes: 55 additions & 0 deletions docs-site/src/components/Button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { ArrowRight } from 'lucide-react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { Button } from './Button';

describe('Button', () => {
it('renders a router link with variant and size data attributes', () => {
render(
<MemoryRouter>
<Button to="/start/" variant="secondary" size="lg" iconEnd={<ArrowRight />}>
Get started
</Button>
</MemoryRouter>,
);
const link = screen.getByRole('link', { name: 'Get started' });
expect(link).toHaveAttribute('href', '/start/');
expect(link).toHaveAttribute('data-variant', 'secondary');
expect(link).toHaveAttribute('data-size', 'lg');
expect(link.querySelector('[data-icon-end]')).toHaveAttribute('aria-hidden', 'true');
});

it('renders an external anchor that opens in a new tab and says so', () => {
render(
<Button href="https://github.com/aws-samples/sample-ai-agent-factory" external>
Source on GitHub
</Button>,
);
const link = screen.getByRole('link', { name: 'Source on GitHub (opens in new tab)' });
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
expect(link).toHaveAttribute('data-variant', 'primary');
});

it('renders a plain anchor for in-page fragments', () => {
render(<Button href="#quickstart">Quickstart</Button>);
const link = screen.getByRole('link', { name: 'Quickstart' });
expect(link).toHaveAttribute('href', '#quickstart');
expect(link).not.toHaveAttribute('target');
});

it('renders a real button that defaults to type="button" and forwards clicks', () => {
const onClick = vi.fn();
render(
<Button variant="ghost" onClick={onClick} aria-pressed="false">
Pause
</Button>,
);
const button = screen.getByRole('button', { name: 'Pause' });
expect(button).toHaveAttribute('type', 'button');
expect(button).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
});
Loading
Loading