@codefusion-cc/features-page
v0.2.0
Published
The structure and behaviour of a public features page, with no words and no look of its own: typed groups of features with anchors, heading order and accessible names, a section bar and index, screenshots in the viewer's light or dark theme with a strip a
Downloads
872
Maintainers
Readme
@codefusion-cc/features-page
The structure and behaviour of a public page of what an app does: groups of features with stable anchors, headings in order, accessible names, a section bar that follows the reader, real screenshots in the viewer's light or dark theme with a scrolling strip and a full-screen view, the checks that keep content and screenshots in step, and the Playwright run that takes the screenshots.
It holds no words and no look. Titles, texts and the page's own strings (FeaturesCopy) come from the app, and
so does every colour, size and layout: each part carries a stable cf-fp-* class and takes the app's own through
classNames. A plain list of titles and texts needs no stylesheet at all.
npm install @codefusion-cc/features-page| Entry | Runs | What |
| --- | --- | --- |
| @codefusion-cc/features-page | anywhere | the content's types, featureAnchor, featureAnchors, slugify, sectionLinks, shotNames, shotFile, parseShotFile, isPhoneShot, KEY_POINTS, SHOT_MODES |
| @codefusion-cc/features-page/react | React 19 | FeatureList, Features, FeaturesIndex, FeaturesNav, Screens, ShotFrame, ShotsProvider, shotUrls, useScrollToHash, useActiveSection |
| @codefusion-cc/features-page/styles.css | the page | only what the strip and the full-screen view need to work |
| @codefusion-cc/features-page/testing | the app's tests | featuresProblems, outlineProblems |
| @codefusion-cc/features-page/screenshots | Node, with Playwright | captureShots, shot, DEVICES, captureArgs, localBase |
No dependencies; React is a peer.
Content
import type { Feature, FeatureGroup } from '@codefusion-cc/features-page'
export const groups: FeatureGroup<'search', 'planned' | 'ready'>[] = [
{
id: 'find', // the group's anchor, and the start of its features'
title: 'Finding things', // optional: without it the group has no heading
label: 'Find', // optional: its name in a section bar
lead: 'One box asks six sources.', // optional
features: [
{
id: 'sources', // optional, stable across languages: the anchor is find-sources in every one
title: 'Six sources at once',
text: 'The app asks every enabled source in parallel.',
gain: 'Results from every site in one list.', // optional
points: ['Most important first', 'The first three show', 'The rest wait under a disclosure'], // optional
status: 'ready', // optional; the words for it are FeaturesCopy.status
shots: [{ src: 'search', alt: 'Results for a Linux release, with release tags' }], // optional
},
],
},
]- A feature's anchor is
<group id>-<id>, or the title as a slug withoutid(slugify: accents dropped, Polish letters too). Two features that end up with one anchor get-2,-3: the page never has a duplicateid(featuresProblemsstill reports it as the mistake it is). In a flat list the anchor is the feature's own part. points: most important first; the firstKEY_POINTS(3) show, the rest are under a<details>.- A shot is a file per mode:
<src>-light.webpand<src>-dark.webp; its size is in theshots.jsonthe screenshot run writes, and a shot taller than wide is markeddata-phone. - An app with fields of its own extends
Featureand reads them inrenderFeature.
Parts
A flat list, as a plain page of titles and texts uses it:
import { FeatureList } from '@codefusion-cc/features-page/react'
<FeatureList
features={content.features}
classNames={{ list: 'divide-y', item: 'py-8', title: 'text-2xl font-bold', text: 'mt-3 text-lg' }}
/>Each feature is a <section id="…" aria-labelledby> (its heading's id is <anchor>--title, which no anchor can be) holding its heading (level, 2 by default), status, gain, text, the
key points, More details as a <details>, renderFeature's body, and the shots with its aside. A part a feature does not
have is not written.
Groups, with a section bar and an index for a long page:
import { Features, FeaturesIndex, FeaturesNav } from '@codefusion-cc/features-page/react'
import { sectionLinks } from '@codefusion-cc/features-page'
<FeaturesNav label={copy.sections} links={[...sectionLinks(groups), { id: 'plans', label: 'Plans' }]} className="sticky top-0" />
<FeaturesIndex groups={groups} />
<Features groups={groups} copy={copy} shots={{ sizes: SIZES, url, mode }} classNames={{ group: 'mt-16', item: 'mt-10' }} linkTitles />- A group becomes a
<section id>with a heading oflevel(2) when it has a title, and its features go one level lower; a group with no title has no heading and its features take its level, so headings never skip one. FeaturesIndexputs each group's title in a heading oflevel(3 by default), for a page that has its own heading above the index.FeaturesNavmarks the section in view witharia-current="location"(measured at most once a frame). Sticking to the top, the phone layout and a "contents" button are the app's.linkTitlesmakes each title a link to its own anchor, described bycopy.featureLink.useScrollToHashlands on the address's anchor once the page has rendered (an app whose router does it itself leaves it out).FeaturesCopy:moreDetails(n)(needed by a feature with more than 3 points),status(words for each status in use),featureLink, andshots(needed by a feature with shots). A missing one that is needed throws when it renders, naming the feature: an app's first test finds it.- Everything is text: titles, texts, points, statuses and alt text are never read as markup.
Screenshots, with shots={{ sizes, url, mode }} on Features or FeatureList, or ShotsProvider around Screens
and ShotFrame for a layout of one's own:
import { shotUrls } from '@codefusion-cc/features-page/react'
const url = shotUrls(import.meta.glob('./shots/*.webp', { eager: true, query: '?url', import: 'default' }))One shot stands alone; several share a height in a strip that snaps shot by shot, with arrows when they do not all fit (an arrow at its end is aria-disabled and keeps focus; smooth paging gives way to prefers-reduced-motion).
A click opens them full screen in a modal <dialog>: arrow keys, buttons or a swipe move round the ends, Escape, Close or a
click beside it close it, and focus goes back to the shot. A shot's alt text names its button (copy.shots.enlarge) and is
the full-screen image's alt; captions also writes it under the shot, which is off since the picture says it.
Look
styles.css (imported once, only where there are screenshots) makes the strip scroll and snap, sizes the shots and
centres the full-screen view. Its few colours are --cf-fp-surface, --cf-fp-ink, --cf-fp-line and --cf-fp-focus, set on any
ancestor; neutral ones that follow color-scheme otherwise. It is in the components layer, so the app's own rules win.
Checks
import { featuresProblems, outlineProblems } from '@codefusion-cc/features-page/testing'
it('has what it shows: anchors, texts, statuses with words, screenshots in both modes', () => {
expect(featuresProblems({ groups, sizes: SIZES, files: Object.keys(import.meta.glob('./shots/*.webp')), statusCopy: copy.status, require: ['gain'] })).toEqual([])
})
it('shows the same features in every language', () => {
for (const other of [pl, de]) expect(outlineProblems(en.groups, other.groups)).toEqual([])
})featuresProblems lists, as sentences: anchors that are not ones or that two features, groups or sections share, empty
titles and texts, repeated or empty points, repeated shots, alt text too short to say what a shot shows, statuses without
words, shots without a size or a file in a mode, and sizes or files nothing shows. require: ['gain', 'points'] also asks
for what the app's page needs of every feature. features checks a flat list. outlineProblems lists where a translation
shows other features, shots, statuses or numbers of points.
Screenshots
A script of the app takes them from the app running locally, in Chromium, each in every mode:
// scripts/screenshots.ts
import { chromium, type Page } from 'playwright'
import { captureArgs, captureShots, DEVICES, localBase, shot } from '@codefusion-cc/features-page/screenshots'
import { themeConfig } from '../src/themeConfig.ts' // the app's defineTheme(…)
const base = localBase(process.env.BASE ?? 'http://localhost:5173')
const browser = await chromium.launch()
const result = await captureShots({
browser,
outDir: new URL('../src/features/shots/', import.meta.url),
sizesFile: new URL('../src/features/shots.json', import.meta.url),
// An app that keeps a theme of its own in the browser starts each context on the run's mode (with
// @codefusion-cc/theme: its config's storageKey). One that follows the system needs nothing: the context has the mode.
prepare: (context, { mode }) => context.addInitScript(([key, m]) => localStorage.setItem(key, m), [themeConfig.storageKey, mode]),
scenes: [
shot('downloads', async (page: Page) => { await page.goto(`${base}/`) }),
shot('downloads-phone', async (page: Page) => { await page.goto(`${base}/`) }, { device: DEVICES.phone }),
{
// Several shots of one setup: two people, one chat.
shots: ['chat', 'chat-phone'],
run: async ({ page, shoot }) => {
const a = await page()
const b = await page(DEVICES.phone)
// …
await shoot(a, 'chat')
await shoot(b, 'chat-phone')
},
},
],
...captureArgs(process.argv, process.env), // --only=chat,downloads --modes=dark (or SHOTS=…, MODES=…)
})
await browser.close()
if (result.failed.length) process.exitCode = 1- Contexts open in the mode's colour scheme with reduced motion;
preparesets what the app keeps in the browser (its stored theme, language, a dismissed consent banner). The run closes every context after its scene. - Before each shot it waits for fonts, for
[aria-busy="true"](busy) to go and for the images in view, thensettleMs; carets are hidden and animations stopped. - WebP by Chromium's own encoder: nothing else to install. Desktop shots are 1920 × 1200 (1280 × 800 at 1.5×), phone shots 780 × 1688 (390 × 844 at 2×).
shots.jsonlists, sorted, every shot with all its modes' files; the files of a shot it listed that no scene takes any more are removed, and any other file in the folder is left alone.--onlytakes some and keeps the rest;--modes=darktakes one mode (a run per mode on fresh data).- A failing scene doesn't stop the run: it is in
result.failedwith why. A script's own mistakes (a shot two scenes take, a name that is not a file name) throw before anything opens. localBaserefuses any address but this machine's: the run writes sample data.
