# Adobe Labs
> Adobe Labs is the always-on public home that makes Adobe's AI-and-creativity innovation visible,
> continuous, and credible; the trusted, human-centered, evidence-led voice on creative work in the AI era.
This site is built using AEM with content managed via Document Authoring (DA). The codebase is based off of the `aem-boilerplate`.
## Environments
- Preview: https://main--adobe-labs-website--adobe.aem.page/
- Live: https://main--adobe-labs-website--adobe.aem.live/
- Editing: https://da.live/#/adobe/adobe-labs-website/
## Documentation
### aem-boilerplate
Before using the aem-boilerplate, we recommend you to go through the documentation on https://www.aem.live/docs/ and more specifically:
1. [Developer Tutorial](https://www.aem.live/developer/tutorial)
2. [The Anatomy of a Project](https://www.aem.live/developer/anatomy-of-a-project)
3. [Web Performance](https://www.aem.live/developer/keeping-it-100)
4. [Markup, Sections, Blocks, and Auto Blocking](https://www.aem.live/developer/markup-sections-blocks)
## Quick Start
```sh
npm i
npm start
```
## Local development
1. Install the [AEM CLI](https://github.com/adobe/helix-cli): `npm install -g @adobe/aem-cli`
1. Start AEM Proxy: `npm start` (opens your browser at `http://localhost:3000`)
1. Open the `adobe-labs-website` directory in your favorite IDE and start coding
## Code Quality
To run ESLint and Stylelint:
```sh
npm run lint
```
## Creating blocks
A block is a named table in a document. Authors insert it; developers style and decorate it. The block name must match a folder under `blocks/` in this repo (for example `blocks/hero/` loads `hero.css` and `hero.js`). See [Markup, Sections, Blocks, and Auto Blocking](https://www.aem.live/developer/markup-sections-blocks).
### Add the block code
1. Create `blocks/<block-name>/` with a CSS file, and a JS file if the block needs decoration.
2. Scope CSS to `.block-name`. Section wrappers use `.block-name-wrapper` / `.block-name-container` — do not put block layout CSS only on those unless you mean to style the section shell.
3. Authors can omit cells and add options in the table header, for example `grid-item (aspect-4/5)`. Options become extra classes on the block (`aspect-4-5`). Decorate defensively.
4. Run `npm start` and place the block on a preview page to verify. Inspect `http://localhost:3000/<path>.plain.html` if the authored markup is unclear.
See [The Anatomy of a Project](https://www.aem.live/developer/anatomy-of-a-project) and [Exploring blocks](https://www.aem.live/docs/exploring-blocks).
### Make the block available to authors
Authors insert blocks from the Library in [Document Authoring](https://da.live/#/adobe/adobe-labs-website). That catalog lives in DA under `/docs/library/`, not in this repo. Code merges ship separately from content publish.
Library lists two kinds of variants:
- **Content variants**: an H2 above each sample table. The heading text is the sub-item name in Library.
- **Visual variants**: options in the table header, as in `grid-item (aspect-4/5)` above. Section breaks are for page layout, not for grouping Library items.
1. Create a document named after the block in the [blocks folder](https://da.live/#/adobe/adobe-labs-website/docs/library/blocks).
2. For each variant, add an H2 (the Library label), then a block table with dummy content. Use header options for visual variants.
3. Optionally add a `Library Metadata` table after a sample block so Library shows an info icon with that description. Wrap a heading with the block in `library-container-start` / `library-container-end` if the heading should insert with the block.
4. Preview the document so Library can read it. Publish if authors on the live site should see it.
5. Add a row on the [blocks spreadsheet](https://da.live/sheet#/adobe/adobe-labs-website/docs/library/blocks):
- `name`: the label shown in Library → Blocks
- `path`: `https://content.da.live/adobe/adobe-labs-website/docs/library/blocks/<block-name>` (use `content.da.live`, not `da.live`)
6. Preview the spreadsheet (publish if live authors need it).
7. In DA, open Library → Blocks and confirm the new name, with nested H2 variants.
### Library setup
Blocks, Templates, Placeholders, and Icons are registered on the **library** tab of the [site config](https://da.live/config#/adobe/adobe-labs-website/) (for example Blocks → `https://content.da.live/adobe/adobe-labs-website/docs/library/blocks.json`). Put those rows on the **library** tab, not **data**. Edit this tab only when adding a new *type* of library, not for each block.
For Templates, Placeholders, and Icons, see [Setup library](https://docs.da.live/administrators/guides/setup-library).
### content-grid (query-driven)
The homepage **Latest Content** section uses `content-grid` with a key/value table:
| Field | Meaning |
| --- | --- |
| Content Type | `All` (default) fetches `/content.json`. A section name — Research, Workflows, Sneaks, Playground — fetches that folder’s `content.json` |
| Category | Optional. `All` or omitted means no filter. Otherwise matched against the index `category` field (array or comma-separated string) after trim + lowercase |
| Count | How many cards to show (defaults to 8) |
| Intro | Optional freeform first cell (heading, paragraph, links). Extra; does not count toward Count |
The block fetches the Content Type endpoint via `dataStore`, filters by Category after the fetch, and renders each hit as a `grid-item`. If nothing matches, the block and its `.content-grid-wrapper` are hidden (including authored Intro). An Intro cell, when authored, sits in column 1 at four columns and stacks full-width above the cards at three columns and one. Card image frames follow the index `imageAspect` value (`1:1`, `4:5`, `3:2`, `2:3`; separators `:`, `/`, or `-` are fine). Missing or unknown values default to 1:1. Video cards get the play icon when the index has `isVideo` true, `contentType` is `video`, or the page lives under `/sneaks/` (Sneaks are video unless `isVideo` is explicitly false). Card content-type labels (when `show-content-type` is set) come from the first path segment (Research, Workflows, Sneaks, Playground)—not the topic Category metadata.
Card subheads default to the publication date (`Oct 21` this year, `Oct 21, 2027` otherwise). Add `subhead-description` to the content-grid block header (`content-grid (subhead-description)`) to use the index description instead.
Standalone `grid-item` cards link when the Title cell is a link. Content-type labels on cards are off by default. Add `show-content-type` to the content-grid block header (`content-grid (show-content-type)`) to render each card’s `.grid-item__content-type` link. On a standalone `grid-item`, author a `Content Type` row (legacy `Category` still works).
Cards stay empty until indexed article pages exist. Index config lives at [tools.aem.live](https://www.aem.live/developer/indexing) (this repo does not contain `helix-query.yaml`).
**Index properties** (reindex after saving):
- Keep `title`, `image`, `description`, `publicationDate`, `robots`
- Add `category` as an array or comma-separated list so the Category filter can match
- Add `isVideo` from `meta[name="isvideo"]` so the play icon can follow page metadata outside `/sneaks/`
- Add `imageAspect` from `meta[name="image-aspect"]` so card frames follow page metadata `Image Aspect`
**On each Labs article in DA**, put the page under `/research`, `/workflows`, `/sneaks`, or `/playground`, and author description, `og:image`, publication date, and `Image Aspect` (`1:1`, `4:5`, `3:2`, or `2:3`). The block drops `noindex` pages and section index pages (`/research/`, `/workflows/index`, and the other known sections).
### content-grid (query-driven)
The homepage **Latest Content** section uses `content-grid` with a key/value table:
| Field | Meaning |
| --- | --- |
| Content Type | `All` (default) fetches `/content.json`. A section name — Research, Workflows, Sneaks, Playground — fetches that folder’s `content.json` |
| Category | Optional. `All` or omitted means no filter. Otherwise matched against the index `category` field (array or comma-separated string) after trim + lowercase |
| Count | How many cards to show (defaults to 8) |
| Intro | Optional freeform first cell (heading, paragraph, links). Extra; does not count toward Count |
The block fetches the Content Type endpoint via `dataStore`, filters by Category after the fetch, and renders each hit as a `grid-item`. If nothing matches, the block and its `.content-grid-wrapper` are hidden (including authored Intro). An Intro cell, when authored, sits in column 1 at four columns and stacks full-width above the cards at three columns and one. Card image frames follow the index `imageAspect` value (`1:1`, `4:5`, `3:2`, `2:3`; separators `:`, `/`, or `-` are fine). Missing or unknown values default to 3:2. Video cards get the play icon when the index has `isVideo` true, `contentType` is `video`, or the page lives under `/sneaks/` (Sneaks are video unless `isVideo` is explicitly false). Card section labels (when `show-category` is set) come from the first path segment.
Card subheads default to the publication date (`Oct 21` this year, `Oct 21, 2027` otherwise). Add `subhead-description` to the content-grid block header (`content-grid (subhead-description)`) to use the index description instead.
Standalone `grid-item` cards link when the Title cell is a link. Section labels on cards are off by default. Add `show-category` to the content-grid block header (`content-grid (show-category)`) to render each card’s `.grid-item__category` link.
Cards stay empty until indexed article pages exist. Index config lives at [tools.aem.live](https://www.aem.live/developer/indexing) (this repo does not contain `helix-query.yaml`).
**Index properties** (reindex after saving):
- Keep `title`, `image`, `description`, `publicationDate`, `robots`
- Add `category` as an array or comma-separated list so the Category filter can match
- Add `isVideo` from `meta[name="isvideo"]` so the play icon can follow page metadata outside `/sneaks/`
- Add `imageAspect` from `meta[name="image-aspect"]` so card frames follow page metadata `Image Aspect`
**On each Labs article in DA**, put the page under `/research`, `/workflows`, `/sneaks`, or `/playground`, and author description, `og:image`, publication date, and `Image Aspect` (`1:1`, `4:5`, `3:2`, or `2:3`). The block drops `noindex` pages and section index pages (`/research/`, `/workflows/index`, and the other known sections).
### Hero (first section)
Keep the hero in its own first section. That is what makes the hero image load quickly.
The page already loads the first image in the first section right away, and AEM lazy-loads the rest. If the content grid, Explore, or article body sit in that same section, the page waits on them before it starts the hero.
In Document Authoring, insert a section break after the hero table. Paste the hero image as a normal picture — you do not need to set `loading` or `fetchpriority`. Adding `fetchpriority="high"` or a preload usually makes Lighthouse scores worse on Edge Delivery; see Adobe’s [keeping-it-100](https://www.aem.live/developer/keeping-it-100) guidance.
Set the headline's heading level in Document Authoring. The hero keeps that element: Heading 1, Heading 2, or the default paragraph. Articles that need a single page title use Heading 1. A page with more than one hero, such as Sneaks, should not use Heading 1 for each one.
#### AEM editing
1. Insert a **section break** after the previous section.
2. Add a **Section Metadata** table in that section.
3. Set **Style** to one surface (same pattern as [full-bleed](#full-bleed-images-in-articles)):
- `section-rounded-default` — default surface
- `section-rounded-blue` — blue surface
- `section-rounded-pink` — pink surface
- `section-rounded-orange` — orange surface
- `section-rounded-gray` — gray surface (`#ececec`). The Economic Impact section uses this.
Keep the **hero in the first section**. Do not put a rounded Style on the hero section.
On the **homepage**, keep Manifesto (`grid-line-content`) in its own last main section.
#### Spacing rules
A `section-rounded-*` section gets vertical padding and `z-index: 1`.
- The first default section in a group, and every color section, get a start corner radius.
- A color section that follows a rounded section, or a default section that follows a color section, overlaps the section before it. The offset is `--section-margin-negative-offset`: `-(radius + --section-space-between)`. A default hero is not overlapped.
- Adjacent default sections appear as one continuous card. The next section cancels the flex gap and start padding. The last default section before a different surface keeps an end radius.
- When motion is opted in (`prefers-reduced-motion: no-preference`), section overlays load Lenis for smooth scrolling on a fine pointer. A coarse pointer skips Lenis. When that browser can run scroll-driven animations, the cover dim, hero-copy fade, and page-header fade run in CSS and the page does not load GSAP. Browsers without that support keep those fades on GSAP. A rounded section overlays whatever section is immediately before it (page header, hero, or another rounded card). Rounded cards pin, lag, and dim once the incoming card reaches `COVER_START_VH` of the viewport (`0.6` by default). Inner content recedes with that cover, from rest, so the outgoing card does not reverse as the next one overtakes it. A page header or default hero does not pin: its content keeps scrolling, just slower, while the first rounded section covers it. A page header and a default hero that share one section stick the header under the hero even when the following section has no `section-rounded-*` class. The header still fades across the first part of the scroll. That following section stays in flow and does not cover the hero. The overlay starts dimming when that overlap begins and eases to full strength as the incoming section covers it. Adjacent default sections do not slow. Full-screen heroes pin in place without shifting under the nav. A pinned card stays parked in the viewport for the rest of the page, and the page-header wrapper stays stuck under the nav after it fades. Focusing a control that a card, the sticky header, or that fade still covers scrolls the page until the control is fully in view (WCAG 2.2 SC 2.4.11). Nothing is taken out of the tab order, made `inert`, or hidden with `visibility`, so every control stays reachable; fades use `opacity` rather than GSAP's `autoAlpha` for the same reason. Lenis additionally forces `scroll-behavior: auto` while it runs, since native smooth scrolling fights it on the skip link, in-page anchors, and hash deep links.
- The last rounded section on the page gets an end radius and uses `--section-padding-block-end-last`. A page with one rounded section gets all four corners.
- After a full-screen hero, the next rounded section overlaps the hero by `-(radius + --section-space-between)`.
- Every last `main > .section` has an end radius and a background (`:last-child`), so the curve paints over the footer. A last section without `section-rounded-*` uses `--section-padding-block-end-last`. The footer overlaps that section by the section radius. Footer inner padding grows by that amount so links stay clear of the curve. Footer start padding increases at 64rem and above. The Adobe logo sticks to the bottom of the viewport and rises from below it as the footer content scrolls past: `.footer__logo-image` translates from 50% down to rest as `--footer-logo-entry-progress` runs from `-100` to `0`. When motion is opted in, `.footer__inner` sticks to the bottom behind the section that sits on the footer, clips, and translates its content as that section uncovers it. The cover is the last `section-rounded-*` section when the page has one. Otherwise it is the last section. A full-screen hero does not cover the footer. A page that only garage-doors the footer loads those styles and skips GSAP and Lenis. The menu stays in the tab order so a keyboard user can reach it; focusing a control that the section still covers scrolls the section off it (WCAG 2.2 SC 2.4.11). After the menu is in, the logo sticky takes over. Reduced motion keeps the radius peek.
- The research index (`template: research`, `body.research`) uses a larger `--section-space-between`, then a larger value at 48rem and above. Set that template on the research index only.
- Article pages (`template: article`) use the default surface only. `--section-padding-block-end` and `--section-space-between` are `--s2a-spacing-2xl`, then `--s2a-spacing-4xl` at 90rem (1440px). The last rounded section still uses `--section-padding-block-end-last`.
## Testing
To run tests:
```sh
npm test
```
## Code Guidelines
### Typography
Three font stacks live on `:root` in `styles/styles.css`.
| Use | Custom property | Face |
| --- | --- | --- |
| Headings | `--heading-font-family` | Adobe Clean Display Black |
| UI and default body | `--body-font-family` | Adobe Clean |
| Article body | `--serif-font-family` | Adobe Clean Spectrum Serif |
`h1`–`h6` and any class that contains `heading-` take size, line height, letter spacing, and weight from the s2a heading tokens in `styles/styles.css`. Use those elements or a `heading-*` class when the design calls for a heading. Article pages keep that heading scale. Serif applies to `p`, `ul`, and `ol` inside `body.article .default-content-wrapper` (16px/20px below 64rem, then 20px/27px).
#### Grayscale font smoothing
Display Black and other heavy weights (700–900) render thicker than the design frames on macOS when the browser uses subpixel antialiasing. Grayscale smoothing matches the frames:
```css
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
```
Both properties inherit. The shared heading rule and `.label` already set them, so a heading element or `heading-*` class needs no extra declarations.
Add both declarations on the same rule that sets the heavy face when that element sits outside `h1`–`h6`, `[class*="heading-"]`, and `.label`:
- `--heading-font-family`, `--s2a-font-weight-heading`, or `--typography-font-weight-heading-2` (Display Black, 900). See the manifesto paragraphs in `grid-line-content`, abstract numbers, the hero, and lead-in headlines.
- `--s2a-font-weight-adobe-clean-black` (900) on `--body-font-family`. See the header wordmark.
- Label and button text: `--s2a-font-weight-label`, `.button`, and `.action-button`.
Regular body copy (`--s2a-font-weight-body`, 400) and article serif paragraphs and lists keep the browser’s subpixel smoothing.
### CSS
For blocks and other custom classes, the preference is to use BEM style classes where possible.
Native nesting can be used for this project. When doing so, keep the following in mind in order to increase the support for some slightly older Safari versions:
1. Use `&` when referencing **base** elements, e.g. `.thing { & p { color: red; }}`. This helps support Safari 16.5 through 17.1 that enforced a strict grammar rule.
2. If using `@supports`, keep this as a root selector and not nested within other selectors, to avoid a Webkit
bug that breaks all the other adjacent styles and CSS custom properties. Safari versions 16.5 through 18.1 were affected by this CSS nesting hoisting bug.
### JS
Make sure all code is documented with JSDOC style comments. Including functions, their parameters, and return values.
Avoid an excessive amount of separate imported files, as each is an a network request since the JS is not compiled into a bundle.
### Writing Block Tests
See the [write-block-tests](.ai/skills/write-block-tests/SKILL.md) skill for instructions and guidelines on writing unit tests for blocks.
## Vendoring third-party JS libraries
This project has no bundler on the request path, so npm packages cannot be imported by name at runtime, and production code should not load libraries from a public CDN. Third-party libraries are vendored instead: a thin re-export under `deps/<library>/src` is bundled once at development time with `esbuild` into a self-contained ESM file at `deps/<library>/dist`, then committed and imported by relative path. This matches the pattern [aemsites/author-kit](https://github.com/aemsites/author-kit) uses to ship Lit without a build system.
### Updating the vendored Lenis library
[Lenis](https://lenis.dev/) (smooth scrolling used by section overlays) is vendored this way. After bumping the version in `package.json`:
```sh
npm install
npm run build:lenis
```
That writes `deps/lenis/dist/index.js` and `deps/lenis/dist/lenis.css`. Commit those files with the version change.
When a feature needs Lenis, import the committed dist file and load the stylesheet at that point. Do not add Lenis to `scripts.js` or `head.html`. Section overlays do this from `scripts/section-scroll/init.js` when motion is opted in, and skip it entirely on touch.
Lenis sets `scrollTop` from its own loop, so it fights anything else that animates the scroll position. `styles/features/section-scroll.css` turns off the native `scroll-behavior: smooth` while Lenis is active, and programmatic scrolls that run before Lenis attaches — the hash deep link in `loadLazy` — jump instantly so Lenis cannot take over mid-flight and strand them short of the target. In-page anchors (the skip link, content-grid pagers) are handled in capture: the native hash jump is prevented, Lenis smooth-scrolls to the target's in-flow offset (sticky `offsetTop` is the pinned box, so a Previous pager would stop short of the section top), focus moves to the destination heading so Tab continues in that section, and `history.pushState` updates the URL. A native hash click would otherwise be undone on the next animation frame, so the first click looks like a no-op.
```js
import Lenis from '../../deps/lenis/dist/index.js';
import { loadCSS } from '../../scripts/aem.js';
loadCSS(`${window.hlx.codeBasePath}/deps/lenis/dist/lenis.css`);
```
### Updating the vendored GSAP library
[GSAP](https://gsap.com/) plus [ScrollTrigger](https://gsap.com/docs/v3/Plugins/ScrollTrigger/) (scroll-driven section parallax) are vendored the same way. After bumping the version in `package.json`:
```sh
npm install
npm run build:gsap
```
That writes `deps/gsap/dist/index.js`. Commit that file with the version change.
When a feature needs GSAP, import the committed dist file at the point of use. Do not add GSAP to `head.html`, and do not import it from `scripts.js`. Section overlays keep every GSAP import inside `scripts/section-scroll/section-motion.js`, which `scripts/section-scroll/init.js` dynamic-imports only once motion is opted in — so the bundle is never reachable from a static import chain.
```js
import { gsap, ScrollTrigger } from '../../deps/gsap/dist/index.js';
```
Anything driven from `gsap.ticker` — Lenis is, in `scripts/section-scroll/init.js` — needs `gsap.ticker.lagSmoothing(0)`, or a slow frame lets the ticker jump time forward and desyncs it from the real scroll position. That setting is global to GSAP, so restore the stock `lagSmoothing(500, 33)` on teardown instead of leaving every later animation on the page without it.
`scripts.js` may still emit a guarded `<link rel="modulepreload">` for the bundle, as `loadLazy` does for section overlays. A dynamic `import()` inside a module cannot be requested until that module's own imports have resolved, so a vendored bundle behind one starts downloading several round trips late. The hint starts the download early without placing the bundle in any import graph, and it must carry the same guard as the import it warms — otherwise it becomes an eager load for requests that never use it.
### Updating the vendored c2pa-web library
`@contentauth/c2pa-web` (used by content credentials / CR pin) is vendored this way. To rebuild it, e.g. after bumping the version in `package.json`:
```sh
npm install
npm run build:c2pa
```
## Query Indexes
The following query indexes are configured for this site.
The custom `content.json` indexes are used to render dynamic content, such as articles within the Content Grid.
- **All pages**: The `sitemap.xml` is configured to point to this.
`/query-index.json`
- **All content**: Returns all types of single article content within specific directories (excludes index pages).
`/content.json`
- **Research content**: Returns all research articles (excludes the index page).
`/research/content.json`
- **Workflows content**: Returns all workflow articles (excludes the index page).
`/workflows/content.json`
- **Sneaks content**: Returns all workflow articles (excludes the index page).
`/sneaks/content.json`
- **Playground content**: Returns all workflow articles (excludes the index page).
`/playground/content.json`
Important development notes:
- The indexes are configured by admins using the [AEM Index Admin Tool](https://tools.aem.live/tools/index-admin/index.html), not via the "retired" method of using a YAML file.
- Per AEM docs, sitemaps should automatically exclude `noindex` robots metadata. They are not automatically
excluded from the query index JSON, so these must be filtered on the frontend.
- Only published pages (and changes) will show in the query indexes.
## Metadata
Default metadata values are set via the root `/metadata` spreadsheet.
See [AEM bulk metadata docs](https://www.aem.live/docs/bulk-metadata) for more info.
Article detail pages (`/research/*`, `/workflows/*`, `/sneaks/*`, `/playground/*`) get `template: article` from that spreadsheet. The article pre-footer autoblock keys off this metadata, not a hardcoded path list. A page-level metadata block can still add or omit `article` for an exception.
Article headings keep the site `h1`–`h6` / `heading-*` tokens (s2a heading-1 through heading-6). Default-content paragraphs and lists use Adobe Clean Spectrum Serif at 16px/20px below 64rem (1024px), then 20px/27px. Font stacks and grayscale smoothing rules are in [Typography](#typography).
Individual pages can then set metadata values via a `metadata` block, including overriding any of those default values.
See [AEM metadata block docs](https://www.aem.live/developer/block-collection/metadata) for more info.
### Dataset download
Article pages show a Download action next to Copy link when the page Metadata table includes a **Download Link** row. After preview, that value is available as `meta[name="download-link"]`.
#### Add a PDF from the DA media folder
1. Upload the PDF under `/media` in [Document Authoring](https://da.live/#/adobe/adobe-labs-website).
2. Preview and publish the file so Edge Delivery can serve it.
3. In the article Metadata table, add **Download Link** and paste either:
- The site path, for example `/media/c4611-sample-explain.pdf`
- The DA media URL, for example `https://da.live/media#/adobe/adobe-labs-website/media/c4611-sample-explain.pdf`
The site rewrites DA media and content URLs for this project (`adobe/adobe-labs-website`) to the same-origin file path. Visitors download the file from the site; they do not land on `da.live`, which is the authoring app.
You can also paste a public file URL (for example an Adobe-hosted PDF).
#### How AEM handles media
- Images and short videos go through [Media Bus](https://www.aem.live/docs/media) when you paste them into a document.
- PDF and SVG files are content files. They follow the normal preview and publish lifecycle and are not stored on Media Bus.
- Document Authoring can upload JPG, PNG, GIF, SVG, PDF, and MP4. See [Adding media](https://docs.da.live/authors/guides/adding-media).
- ZIP is not a DA media type. Host the ZIP (AEM Assets or another public URL) and paste that URL into **Download Link**.
- Do not drop a ZIP or PDF into the article body as if it were an image.
### Feedback
Article pages include a Feedback action with Copy link. It opens a message to labs@adobe.com. The subject is the **Title** row from the page Metadata table. After preview, AEM publishes that row as `og:title`. When Title is empty, the link has no subject.
### Full-bleed images in articles
To use full-bleed default content in an article (for example a lone image), in the AEM editor use a section break and a Section Metadata block that includes "full-bleed" as a value for "Style". Keep that content in its own section.
### Author byline images
Article pages show a "Words by:" byline built in JS from the `author` metadata (not a block — see `buildArticleAuthorMeta` in `scripts/utils/utils.js`). To make an author's photo appear, upload it to `media/authors/<slugified-name>.png` in [Document Authoring](https://da.live/#/adobe/adobe-labs-website/media/authors) and preview it. The filename must exactly match the slugified form of the name authored in the `author` field — lowercase, spaces and other non-alphanumeric characters replaced with hyphens (for example, an author named "Richard Example" needs `richard-example.png`).
- No `author` metadata → the byline falls back to "Adobe Labs" and its logo (`media/authors/adobe-labs.png`).
- A named author with no matching image uploaded → the name shows as plain text; no broken-image icon, no layout shift.
- A named author with a matching image → the photo shows next to their name.
DA rewrites uploaded filenames to a hash on publish, but requesting the original filename redirects to the hashed asset once the file has been previewed — that's what makes the slug-based lookup work without a per-author authoring field.
### Table of Contents
Article pages can list opted-in sections. Insert an empty **Table of Contents** block where the list should appear. The table header is the block name.
On each section that should appear, add a Section Metadata row named **Table of Contents**. The cell value is the label in the list. The block links that label to the section heading. If a section has both rows, the **Table of Contents** value is the label. If no section has either row, the block removes itself.
### Visually-hidden headline
Use this when a section's first item is not a heading and assistive technology still needs one. In that section, add a Section Metadata row named **Visually-hidden headline**. The page renders that row's text as a visually hidden heading level 2. The text is in the page for screen readers. It does not appear in the layout.
The row is omitted when the section's first content item is already a heading (`h1`–`h6`).
## Email collection
The Subscribe buttons in the header and footer open `/modals/subscribe`. That fragment holds an **Email collection** block with the `mailing-list` option: `email-collection (mailing-list)`.
The block needs three rows and a Section Metadata table in the same section.
1. Form. A heading, a short description, a blank line, then a bold link whose href is `#submit`. The live AI Research modal uses the label Subscribe.
2. Success. The success sentence and a link whose href is `#close-form`. An optional `#show-form` link returns to an empty form.
3. Error. The error sentence and a `#close-form` link.
Section Metadata (not shown in the modal):
| Name | Value |
| --- | --- |
| email | Email address |
| country | Country |
| mps-sname | adbe_ml_ai_research |
| subscription-name | AI Research |
| sign-in | off |
`email`, `mps-sname`, and `subscription-name` are required. The form is guest-only. Keep `sign-in | off` for parity with Milo; other values are ignored. A submit requests an IMS guest token with the `adlabs` client id.
Consent text and the “required” / “valid email” messages are loaded from federal content. Authors do not write them. `subscription-name` is inserted into the consent sentence.
On preview hosts, `?email-collection-show=success` or `?email-collection-show=error` opens the modal on that state.
### Troubleshooting
A failed submit shows the error panel. Two causes showed up while wiring the guest path. Both happen after the visitor submits, and both are fixed in `blocks/email-collection/email-collection.js`.
#### Guest token and `ims_sid`
Localhost and other non-production hosts ask stage IMS for a guest token:
`POST https://adobeid-na1-stg1.services.adobe.com/ims/check/v6/token`
```
guest_allowed=true
client_id=adlabs
scope=AdobeID,openid
```
Production uses `https://adobeid-na1.services.adobe.com/ims/check/v6/token` with the same body.
`imslib` sends cookies on that request. A browser that already has an `ims_sid` cookie for the IMS host gets HTTP 200 and this body:
```json
{"error":"invalid_credentials"}
```
The library surfaces that as an expired token (`invalid_credentials`, often with a trailing space because it joins `error` and an empty `error_description`). The same request with no cookies returns a guest `access_token`. A clean browser profile succeeds for that reason, while a signed-in Adobe browser fails.
The block posts the check itself with `credentials: 'omit'`, so an existing Adobe session cookie stays off the guest request. On stage, the `adlabs` client also returns a token when `fingerprint_request_id` is omitted, including a request that never loads the bot-fingerprint script.
#### `aepCmp` and `aepOtherConsents`
The guest token is then sent to `/form-submit` on `https://www.stage.adobe.com/milo-email-collection-api` (stage) or `https://www.adobe.com/milo-email-collection-api` (production).
If `aepCmp` or `aepOtherConsents` is absent from the JSON, the API responds 400:
```json
{"error":"missing parameter(s) 'aepCmp,aepOtherConsents'"}
```
`JSON.stringify` drops a key whose value is `undefined`, and the API treats that as missing. Milo copies both fields from Alloy (`window.alloy_all.data._adobe_corpnew`). This site does not load Alloy. Sending both keys as `null` is accepted: 201 and `Email subscribed successfully`.
Those two fields are separate from the newsletter consent. The newsletter consent is `consentId` from federal content (`cs4;ve1;en` when no consent key is authored).
## Buttons
The default `.button` class uses the Primary style. So far only the default/primary style is supported until others are needed.
Default buttons are dark-mode aware. The `.button--static-white` variant can be used for non-theme-aware buttons, like in the hero.
### Adding a button in AEM
Follow [AEM's Buttons docs](https://www.aem.live/developer/block-collection/buttons#code).
The paragraph must contain only the link. `decorateButtons` then adds class `button` to the link and class `button-wrapper` to the paragraph.
`.button-wrapper` is a `p`, so it has the default paragraph margin.
You can use the same steps for a standalone button in default content and for a button in a block cell.
> [!NOTE]
> The AEM docs incorrectly state that `p > a` (without `<strong>` or `<em>`). This is outdated, as standalone plain links do not receive the `.button` class. See [decorateButtons](https://github.com/adobe/adobe-labs-website/blob/9cd669eb7227dfea2350286db816361dc8bd55da/scripts/scripts.js#L123).
### Adding a button in JavaScript
Create a native `button` or `a` as needed and add class `button` to it.
Add extra classes for variants as needed, for example `button--static-white`.
#### Disabled buttons as links
`decorateButtons` runs before block JavaScript. For a disabled link that you create as `a.button` in block JS, set `aria-disabled="true"`, set `tabIndex = "-1"`, and call `event.preventDefault()` on click.
## Image Content Authenticity and CR Pin
A feature is included that displays content authenticity (C2PA) info in a
popover, denoted and accessed through a "CR" pin icon button in the corner
of some images.
### `c2pa-web` SDK ###
This feature uses and loads the `@contentauth/c2pa-web` SDK in order to read the manifest of credentialed images. The WASM binary of this library is quite large, so steps were taken to prevent this from affecting initial load:
- Scripts only start loading if on the article page.
- Scripts load via loadDelayed(), and after a few seconds, to avoid interference with initial load and first interaction.
- The larger wasm file required by the SDK only loads if targeted images are found.
This library is bundled into deps, following the documented convention used by other dependencies. This adds an npm script for building, `build:c2pa`.
### Frontend UI
If credentials are found on an image, it will have a wrapper added around it, and an absolutely positioned CR pin button will be displayed in the upper right corner. The CR pin button can be activated on hover (requested behavior), and also can be activated by keyboard. When activated, it will display a popover containing info from the credentials data.
Pins are added to images in image blocks, side-by-side image blocks, and article text. Images inside other blocks, such as linkboxes and content grids, are not read.
Article hero images work differently:
- The hero media is `aria-hidden`, so the image is not wrapped. The pin is added to the hero's content grid (`.cr-pin-image--hero`), at the bottom right, in line with the headline. Its popover opens up and to the left.
- A hero wrapped in a link never gets a pin, because a button cannot sit inside a link. The image is not fetched.
- On a full-screen hero, the pin recedes with the headline as the page scrolls. Both read `--section-scroll-hero-text-y`, which `scripts/section-scroll/section-motion.js` sets on the hero section.
What data is displayed in the popover and where it is pulled from the raw manifest data is based on the now deprecated web component from `contentauth/c2pa-js-legacy`. There also is an inspect button with an external link for viewing the Disclosure Level 3 data.