Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a2d49c17f | ||
|
|
b244287f6a | ||
|
|
c4513588b1 | ||
|
|
e652b54319 | ||
|
|
0c2b4e7df2 |
No files matched your search
@@ -0,0 +1,111 @@
|
|||||||
|
# design-sync notes — ciphercell-homepage
|
||||||
|
|
||||||
|
## What this repo is, for sync purposes
|
||||||
|
|
||||||
|
- **The site itself is not a component library.** It is Astro: `.astro` files compile to HTML
|
||||||
|
at build time and have no runtime form, so they cannot be bundled or shipped to a design
|
||||||
|
agent. The first sync therefore went out tokens-only, and the Claude Design component pane
|
||||||
|
was empty — correct output, but useless to design with.
|
||||||
|
- **What ships instead is `.design-sync/ds-src/`**: fourteen React primitives that emit
|
||||||
|
exactly the classes `src/styles/base.css` already defines, and no CSS of their own. That is
|
||||||
|
the point — the visual truth stays in the site's stylesheet, so the design system cannot
|
||||||
|
drift from the site even as `base.css` changes, and each component's markup ports back to
|
||||||
|
`.astro` one to one (every `<Name>.md` states the Astro equivalent).
|
||||||
|
- **Do not port the page-level `.astro` components** (Hero, Stages, HowItWorks, GridObject).
|
||||||
|
Their styles are scoped inside the `.astro` files, so a React copy would duplicate CSS and
|
||||||
|
drift. The line is: global primitive classes are fair game, component-scoped styles are not.
|
||||||
|
- `Stack` is the one component with no class behind it. Nothing in this system carries its own
|
||||||
|
margins, so without it the design agent hand-rolls pixel gaps, which the brief forbids. It
|
||||||
|
emits `display: grid; gap: var(--space-N)` and nothing else.
|
||||||
|
- Ownership: `ds-src/*.tsx` + `ds-src/*.md` are the source; `ds-types/` is generated by
|
||||||
|
`tsc -p .design-sync/ds-src/tsconfig.json`; `previews/*.tsx` are the authored preview
|
||||||
|
cards. Regenerate the types after ANY edit to a component's props, or the design agent gets
|
||||||
|
a contract that does not match the code.
|
||||||
|
|
||||||
|
## Repo-specific gotchas
|
||||||
|
|
||||||
|
- **React and its types come from `.ds-sync/node_modules` by symlink**, so the site's
|
||||||
|
`package.json` keeps no React dependency: `ln -sfn ../.ds-sync/node_modules/react
|
||||||
|
node_modules/react` (same for `react-dom`), plus `ln -sfn ../../.ds-sync/node_modules/@types/react
|
||||||
|
node_modules/@types/react` — that last one is what lets `tsc` resolve `ReactNode` when
|
||||||
|
emitting `ds-types/`, and what keeps the converter from collapsing inherited props to `any`.
|
||||||
|
**Recreate all three links after any `npm install`** in the repo root — installs prune them.
|
||||||
|
- **`package.json` carries `"types": ".design-sync/ds-types/index.d.ts"`.** That is how the
|
||||||
|
converter discovers the components and their props (`findTypesRoot` → `exportedNames`).
|
||||||
|
It is inert for Astro: the package is private and never published. Remove it and the sync
|
||||||
|
silently falls back to tokens-only with an empty component pane.
|
||||||
|
- **`--node-modules` must be `./node_modules`**, not `.ds-sync/node_modules`. The font
|
||||||
|
containment bound is the git repo enclosing `dirname(--node-modules)`, and **this repo is not
|
||||||
|
a git repo**, so the bound collapses to that directory itself. Pointing at `.ds-sync` puts
|
||||||
|
`public/fonts/` outside the bound and the fonts are silently refused.
|
||||||
|
- **`cssEntry` is derived, not the site's own file.** Run `node .design-sync/make-ds-styles.mjs`
|
||||||
|
before the converter. It rewrites exactly two things in `src/styles/base.css`:
|
||||||
|
- `@import './tokens.css'` → the token file's **contents, inlined**. The converter copies the
|
||||||
|
CSS entry verbatim, so an `@import` pointing outside the bundle dangles and every
|
||||||
|
`var(--token)` resolves to nothing. This is silent: the build and validate both pass, the
|
||||||
|
fonts look right, and the whole palette is simply missing. Check
|
||||||
|
`grep -- '--paper:' ds-bundle/_ds_bundle.css` after any build.
|
||||||
|
- `url('/fonts/…')` → `url('../public/fonts/…')`. The site serves fonts from the web root, so
|
||||||
|
the URLs are absolute; `lib/css.mjs` resolves them with `resolve(srcDir, url)`, an absolute
|
||||||
|
path resolves to `/fonts/…` on disk, fails, and the rule is emitted **verbatim** — leaving
|
||||||
|
`fonts/fonts.css` pointing at a path that does not exist. Every design then renders in a
|
||||||
|
fallback font with nothing flagged.
|
||||||
|
- **The brief cannot ship under its own name.** `claude.ai/design` refuses any uploaded path
|
||||||
|
whose basename is `CLAUDE.md` (and anything under `.claude/`) with
|
||||||
|
`permission_denied: … 1 path(s) are reserved`, and the converter names each guideline file
|
||||||
|
after its source (`lib/docs.mjs:emitGuidelines` uses the source's package-relative path).
|
||||||
|
So `guidelinesGlob` points at **`docs/design-brief.md`**, a verbatim copy of `CLAUDE.md` that
|
||||||
|
`make-ds-styles.mjs` regenerates, and it uploads as `guidelines/docs/design-brief.md`. Edit
|
||||||
|
`CLAUDE.md`; never edit the copy. The refusal is a hard API rule, not a warning, and it
|
||||||
|
rejects the whole `write_files` call without writing any of its other paths.
|
||||||
|
- **`cfg.tokensGlob` does not work here.** `lib/css.mjs:copyTokens` resolves it inside
|
||||||
|
`node_modules` for a tokens *package*; these tokens live in the repo. `tokens/` is therefore
|
||||||
|
empty by design and the definitions ship inside `_ds_bundle.css`, which is in the `styles.css`
|
||||||
|
import closure — which is what designs actually receive. Do not chase the empty directory.
|
||||||
|
- **`.design-sync/overrides/css.mjs` fork**: `cpSync` of the font files fails with `EACCES` on
|
||||||
|
this machine's virtiofs mount (`/home/ciphercell/projects`), creating a 0-byte, write-only
|
||||||
|
file and aborting the build. The fork copies with `readFileSync`/`writeFileSync` instead,
|
||||||
|
which works. Reproducible, not transient. Declared in `cfg.libOverrides`.
|
||||||
|
- **This repo is not under git**, so none of the durable files can be committed. If it is ever
|
||||||
|
initialised, commit `.design-sync/{config.json,NOTES.md,conventions.md,make-ds-styles.mjs,
|
||||||
|
ds-src/,ds-types/,previews/,overrides/}` plus the generated `docs/design-brief.md` and the
|
||||||
|
`types` field in `package.json` (the sync needs both present), and keep `.ds-sync/`,
|
||||||
|
`ds-bundle/` and `.design-sync/.cache/` ignored (already in `.gitignore`).
|
||||||
|
|
||||||
|
## Known render warns
|
||||||
|
|
||||||
|
None outstanding. Two `[GRID_OVERFLOW]` warns fired once on `Grid` and `Display`, whose
|
||||||
|
stories are wider than a grid cell; both are resolved by `cfg.overrides.*.cardMode: "column"`
|
||||||
|
and cannot re-fire by construction. All 14 cards render clean, 30 cells graded `good`.
|
||||||
|
|
||||||
|
The card scaffold sets `body{background:#fff}` AFTER the stylesheets, so a preview that does
|
||||||
|
not wrap in `Surface` renders on white instead of paper. Every authored preview wraps in
|
||||||
|
`Surface` for that reason. Keep it that way.
|
||||||
|
|
||||||
|
## Re-sync risks — what can go stale
|
||||||
|
|
||||||
|
- **`.design-sync/ds-styles.css` is generated and gitignored-adjacent.** It is a copy of
|
||||||
|
`base.css` at sync time. If `src/styles/base.css` or `tokens.css` change and nobody re-runs
|
||||||
|
`make-ds-styles.mjs`, the sync ships the old look with no warning. Always regenerate first.
|
||||||
|
The script throws if `base.css` stops `@import`ing `./tokens.css`, which is the one structural
|
||||||
|
change that would break it silently otherwise.
|
||||||
|
- **`conventions.md` enumerates real token and class names.** It was validated against the build
|
||||||
|
at sync time (30 tokens, 25 classes, all present). Renaming or removing a token or primitive
|
||||||
|
class in `src/styles/` makes the header lie to the design agent, which will then write
|
||||||
|
vocabulary that does not resolve. Re-validate on every sync.
|
||||||
|
- **The brief in `guidelines/docs/design-brief.md`** is a generated copy of the repo's own
|
||||||
|
`CLAUDE.md`, which mixes the design brief with project instructions. It is shipped whole on
|
||||||
|
purpose — it is the system's rulebook — but if it ever grows non-design content, narrow what
|
||||||
|
`make-ds-styles.mjs` copies. The copy is generated, so a `CLAUDE.md` edit that nobody
|
||||||
|
regenerates ships the old brief silently: always run the script before the converter.
|
||||||
|
- **The React symlinks are machine state**, not committed. A fresh clone needs them recreated
|
||||||
|
before the converter will run.
|
||||||
|
- **`conventions.md` was rewritten** when the components landed; the tokens-only version that
|
||||||
|
said `window.CipherCell` is empty is gone. If a future sync ever drops back to tokens-only,
|
||||||
|
that file has to change with it — a header that promises components which do not exist is
|
||||||
|
worse than no header.
|
||||||
|
- **Preview cards are verified by rendering**: `package-validate.mjs` screenshots all 14, and
|
||||||
|
`package-capture.mjs` captures each of the 30 cells for grading. Grades live in the
|
||||||
|
gitignored `.cache/review/`; what makes them durable is the uploaded `_ds_sync.json`.
|
||||||
|
- **`_screenshots/` must never be uploaded.** It is local review state and is not in the
|
||||||
|
approved write paths; including it fails the whole `write_files` call.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"projectId": "a8a613fc-8b82-4ce8-a202-0f1016f94e79",
|
||||||
|
"pkg": "ciphercell-homepage",
|
||||||
|
"globalName": "CipherCell",
|
||||||
|
"shape": "package",
|
||||||
|
"cssEntry": ".design-sync/ds-styles.css",
|
||||||
|
"guidelinesGlob": [
|
||||||
|
"docs/design-brief.md"
|
||||||
|
],
|
||||||
|
"readmeHeader": ".design-sync/conventions.md",
|
||||||
|
"libOverrides": {
|
||||||
|
"css.mjs": "cpSync of font files fails EACCES on this virtiofs mount; fork copies via read+write"
|
||||||
|
},
|
||||||
|
"entry": ".design-sync/ds-src/index.tsx",
|
||||||
|
"srcDir": ".design-sync/ds-src",
|
||||||
|
"tsconfig": ".design-sync/ds-src/tsconfig.json",
|
||||||
|
"overrides": {
|
||||||
|
"Grid": {
|
||||||
|
"cardMode": "column"
|
||||||
|
},
|
||||||
|
"Display": {
|
||||||
|
"cardMode": "column"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# CipherCell — build with the primitives, never with raw CSS
|
||||||
|
|
||||||
|
CipherCell's site is Astro. What ships here is the layer underneath it: fourteen React
|
||||||
|
primitives that emit exactly the classes the site's own stylesheet defines, plus the tokens
|
||||||
|
and the typeface. Styling lives in the stylesheet, not in the components, so a design built
|
||||||
|
from these parts cannot drift from the site, and its markup ports to `.astro` one to one.
|
||||||
|
Each component's `.prompt.md` gives that Astro equivalent.
|
||||||
|
|
||||||
|
## Wrapping
|
||||||
|
|
||||||
|
Every screen starts with `Surface`. It sets the paper ground, the typeface and the base
|
||||||
|
size; without it a design inherits the canvas defaults and none of CipherCell's typography.
|
||||||
|
`Surface` takes `tone="paper"` (the default) or `tone="night"`.
|
||||||
|
|
||||||
|
Inside it the order is `Section` → `Container` → `Grid` → `Col`. A `Section` carries the
|
||||||
|
vertical air. A `Container` holds the page margins and the maximum width. Nothing else may
|
||||||
|
set page margins.
|
||||||
|
|
||||||
|
There is no provider, no theme object and no load order to get right.
|
||||||
|
|
||||||
|
## The idiom
|
||||||
|
|
||||||
|
Compose from the components. Reach for your own CSS only for layout glue the primitives do
|
||||||
|
not cover, and then use tokens for every value: a raw hex or pixel value is a defect in this
|
||||||
|
system. Do not invent class names; the stylesheet has no utility classes and yours will not
|
||||||
|
resolve.
|
||||||
|
|
||||||
|
**Components.** `Surface`, `Section`, `Container`, `Grid`, `Col`, `Stack` for structure.
|
||||||
|
`Display`, `Heading`, `Text`, `Eyebrow` for type. `Button`, `TextLink` for actions.
|
||||||
|
`Prose`, `Rule` for content.
|
||||||
|
|
||||||
|
**Spacing.** Nothing carries its own margins, so two blocks placed together sit flush.
|
||||||
|
`Stack` is what separates them, and its `gap` is a step on the space scale, 1 to 9. Never
|
||||||
|
hand-roll a pixel gap.
|
||||||
|
|
||||||
|
**Tokens** for your own CSS: `--paper`, `--paper-deep`, `--ink`, `--ink-muted`, `--night`,
|
||||||
|
`--line`, `--overlay`; `--font-sans`; the sizes `--step-display`, `--step-h2`, `--step-title`,
|
||||||
|
`--step-h3`, `--step-body`, `--step-small`, each with a matching `--track-*` and `--leading-*`;
|
||||||
|
`--weight-regular` and `--weight-medium`, and no bold; `--space-1` … `--space-9`, `--section`,
|
||||||
|
`--gutter`, `--gap`, `--container`, `--measure`; `--radius` (0) and `--radius-sm` (2px);
|
||||||
|
`--hairline`; `--dur-fast`, `--dur`, `--dur-slow` and `--ease`.
|
||||||
|
|
||||||
|
**There is no accent colour, and there are no shadows.** Links and buttons work through
|
||||||
|
underline, weight and surface. Separation comes from a `Rule` or from a change of surface.
|
||||||
|
Inside a night surface the ink tokens flip themselves, so muted text, hairlines and buttons
|
||||||
|
all correct themselves with nothing to set.
|
||||||
|
|
||||||
|
## Where the truth lives
|
||||||
|
|
||||||
|
Read each component's `.prompt.md` before using it: it carries the usage judgment and the
|
||||||
|
Astro equivalent. `.d.ts` files carry the props. `styles.css` and its imports hold every token
|
||||||
|
and class definition. The written brief is in `guidelines/`, and it governs attitude, colour,
|
||||||
|
typography, layout, imagery, motion and language, including the patterns that are banned.
|
||||||
|
|
||||||
|
## Building with it
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Surface tone="night">
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Grid>
|
||||||
|
<Col span={7}>
|
||||||
|
<Heading>Security is built in,<br />not bolted on.</Heading>
|
||||||
|
</Col>
|
||||||
|
<Col span={4} start={9}>
|
||||||
|
<Stack gap={5} align="start">
|
||||||
|
<Text measure>Every interface between components is an attack surface.</Text>
|
||||||
|
<Button href="/contact">Talk to us</Button>
|
||||||
|
</Stack>
|
||||||
|
</Col>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
```
|
||||||
|
|
||||||
|
Note what this does: the blocks sit asymmetrically rather than centred or evenly split, the
|
||||||
|
line break in the headline is placed by hand, the button names the action, and every value
|
||||||
|
that is not a grid position comes from a token.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
category: Controls
|
||||||
|
---
|
||||||
|
|
||||||
|
# Button
|
||||||
|
|
||||||
|
The filled action. Ink on paper, and paper on ink inside a night surface, which it handles
|
||||||
|
itself. There is no accent colour and no secondary button variant: a second action on the
|
||||||
|
screen is a TextLink.
|
||||||
|
|
||||||
|
Pass `href` for navigation and it renders an anchor. Omit it for an in-page action.
|
||||||
|
|
||||||
|
The label names the action. "Talk to us", "Explore the method", "Request a verification".
|
||||||
|
Never "Learn more", and never an arrow inside the label.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Button href="/contact">Talk to us</Button>
|
||||||
|
<Button size="small">Copy the report</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro equivalent: `<a class="button" href="/contact">`.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { cx } from './cx';
|
||||||
|
|
||||||
|
export interface ButtonProps {
|
||||||
|
/** Renders an anchor when set, a button element otherwise. */
|
||||||
|
href?: string;
|
||||||
|
size?: 'default' | 'small';
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A filled action. Its label names the action: "Talk to us", never "Learn more". */
|
||||||
|
export function Button({ href, size = 'default', children }: ButtonProps) {
|
||||||
|
const className = cx('button', size === 'small' && 'button--small');
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<a className={className} href={href}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button className={className} type="button">
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
category: Layout
|
||||||
|
---
|
||||||
|
|
||||||
|
# Col
|
||||||
|
|
||||||
|
One block on the grid. `span` is how many of the twelve columns it occupies; `start` is the
|
||||||
|
column it begins at, counting from 1.
|
||||||
|
|
||||||
|
Use `start` to leave a gap. An offset block is the house style: content that begins at
|
||||||
|
column 7 or 9 rather than immediately after its neighbour reads as deliberate, and the empty
|
||||||
|
columns are doing work.
|
||||||
|
|
||||||
|
A full-width block is `span={12}`, which is also the default.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Col span={5} start={8}>
|
||||||
|
<Text measure>Every interface between components is an attack surface.</Text>
|
||||||
|
</Col>
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro equivalent: `<div style="grid-column: 8 / span 5;">`. Grid positions are the one place
|
||||||
|
a raw number is allowed; every other value comes from a token.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface ColProps {
|
||||||
|
/** How many of the twelve columns this block occupies. */
|
||||||
|
span?: number;
|
||||||
|
/** The column it starts at, counting from 1. Offsets are how asymmetry is made. */
|
||||||
|
start?: number;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One block on the grid. A headline takes 7 or 8 columns, its body copy 4 or 5. */
|
||||||
|
export function Col({ span = 12, start, children }: ColProps) {
|
||||||
|
return <div style={{ gridColumn: start ? `${start} / span ${span}` : `span ${span}` }}>{children}</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
category: Layout
|
||||||
|
---
|
||||||
|
|
||||||
|
# Container
|
||||||
|
|
||||||
|
Centres content to the page width and holds the page margins. Every band of text sits in
|
||||||
|
one. The only things that may escape it are full-bleed images and video.
|
||||||
|
|
||||||
|
Container and Grid are separate on purpose: a band with a single column of text needs the
|
||||||
|
Container and no grid at all.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Container>
|
||||||
|
<Grid>…</Grid>
|
||||||
|
</Container>
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro equivalent: `<div class="container">`.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface ContainerProps {
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Centres content to the page width and holds the page margins. */
|
||||||
|
export function Container({ children }: ContainerProps) {
|
||||||
|
return <div className="container">{children}</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
category: Type
|
||||||
|
---
|
||||||
|
|
||||||
|
# Display
|
||||||
|
|
||||||
|
The largest type on the page, and the one place the page raises its voice. One per screen,
|
||||||
|
in the hero.
|
||||||
|
|
||||||
|
Break its lines by hand with `<br />`. A display headline is a composed object, not a
|
||||||
|
paragraph that happens to be large, and where the line turns changes how it reads.
|
||||||
|
|
||||||
|
It renders an `h1` by default. Keep one `h1` per page; if the hero headline is not the
|
||||||
|
page's heading, set `as="p"`.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Display>
|
||||||
|
Security engineering<br />for critical software.
|
||||||
|
</Display>
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro equivalent: `<h1 class="display">`.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface DisplayProps {
|
||||||
|
/** h1 by default. One h1 per page. */
|
||||||
|
as?: 'h1' | 'h2' | 'p';
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The largest type on the page. Break its lines by hand; it is a composed object. */
|
||||||
|
export function Display({ as: Tag = 'h1', children }: DisplayProps) {
|
||||||
|
return <Tag className="display">{children}</Tag>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
category: Type
|
||||||
|
---
|
||||||
|
|
||||||
|
# Eyebrow
|
||||||
|
|
||||||
|
A short label above a headline, in sentence case, muted, at small size.
|
||||||
|
|
||||||
|
Use it only when the section genuinely needs naming. An eyebrow above every heading is an
|
||||||
|
anti-pattern here, and it must never be set in all caps.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Eyebrow>Insights</Eyebrow>
|
||||||
|
<Heading>What a verification report owes you.</Heading>
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro equivalent: `<p class="eyebrow">`.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface EyebrowProps {
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A label above a headline, in sentence case. Never all caps, and never decorative. */
|
||||||
|
export function Eyebrow({ children }: EyebrowProps) {
|
||||||
|
return <p className="eyebrow">{children}</p>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
category: Layout
|
||||||
|
---
|
||||||
|
|
||||||
|
# Grid
|
||||||
|
|
||||||
|
The twelve-column grid. It is the tool for asymmetry, which is how this site composes a
|
||||||
|
band: a headline across 7 or 8 columns, its supporting copy across 4 or 5, offset against
|
||||||
|
each other rather than centred or evenly split.
|
||||||
|
|
||||||
|
Place children with Col. Do not nest a Grid inside a Col unless the inner block genuinely
|
||||||
|
has its own twelve-column structure.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Grid>
|
||||||
|
<Col span={7}><Heading>Security is built in, not bolted on.</Heading></Col>
|
||||||
|
<Col span={4} start={9}><Text measure>…</Text></Col>
|
||||||
|
</Grid>
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro equivalent: `<div class="grid">`.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface GridProps {
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The twelve-column grid. Place children with Col, asymmetrically. */
|
||||||
|
export function Grid({ children }: GridProps) {
|
||||||
|
return <div className="grid">{children}</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
category: Type
|
||||||
|
---
|
||||||
|
|
||||||
|
# Heading
|
||||||
|
|
||||||
|
A section headline. `level={2}` is the headline of a band, `level={3}` a block inside it.
|
||||||
|
The level sets both the element and the size, so the document outline and the visual
|
||||||
|
hierarchy cannot drift apart. Never skip a level.
|
||||||
|
|
||||||
|
A headline may state a claim. It never advertises, and it ends with a full stop.
|
||||||
|
Contrast pairs carry a lot of weight here: "Verifiable, not asserted."
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Heading>Four stages. One method.</Heading>
|
||||||
|
<Heading level={3}>Threat analysis</Heading>
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro equivalent: `<h2 class="h2">` and `<h3 class="h3">`.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface HeadingProps {
|
||||||
|
/** 2 for a section headline, 3 for a block within it. Never skip a level. */
|
||||||
|
level?: 2 | 3;
|
||||||
|
id?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A section headline. It may state a claim, but it never advertises. */
|
||||||
|
export function Heading({ level = 2, id, children }: HeadingProps) {
|
||||||
|
const Tag = level === 3 ? 'h3' : 'h2';
|
||||||
|
return (
|
||||||
|
<Tag id={id} className={level === 3 ? 'h3' : 'h2'}>
|
||||||
|
{children}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
category: Content
|
||||||
|
---
|
||||||
|
|
||||||
|
# Prose
|
||||||
|
|
||||||
|
Long-form text: an insight, a legal page, anything written as a document rather than
|
||||||
|
composed as a layout. It styles the headings, lists, quotes, links and figures inside it and
|
||||||
|
caps the measure, so the markup underneath stays plain.
|
||||||
|
|
||||||
|
Use it for a block of authored copy. Do not use it as a general text wrapper; a single
|
||||||
|
paragraph in a layout is a Text.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Prose>
|
||||||
|
<h2>What a verification report owes you</h2>
|
||||||
|
<p>Every finding names the file, the line and the reasoning.</p>
|
||||||
|
<ul><li>Reproduction steps</li><li>The fix we would ship</li></ul>
|
||||||
|
</Prose>
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro equivalent: `<div class="prose">`.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface ProseProps {
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Long-form text: an insight, a legal page. Styles the headings and lists inside it. */
|
||||||
|
export function Prose({ children }: ProseProps) {
|
||||||
|
return <div className="prose">{children}</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
category: Content
|
||||||
|
---
|
||||||
|
|
||||||
|
# Rule
|
||||||
|
|
||||||
|
A hairline. It separates, and it is allowed only where the separation carries information:
|
||||||
|
between the items of a list that genuinely divide, or under the header.
|
||||||
|
|
||||||
|
There are no shadows and no borders around cards in this system. Separation comes from a
|
||||||
|
hairline or from a change of surface, and nothing else.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Rule />
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro equivalent: `<hr class="rule">`.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export interface RuleProps {
|
||||||
|
/** Nothing. A rule separates; it never decorates. */
|
||||||
|
children?: never;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A hairline. Use it only where it carries information. */
|
||||||
|
export function Rule(_props: RuleProps) {
|
||||||
|
return <hr className="rule" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
category: Layout
|
||||||
|
---
|
||||||
|
|
||||||
|
# Section
|
||||||
|
|
||||||
|
One horizontal band of the page. It carries the vertical air above and below its content,
|
||||||
|
which is the single biggest reason the site reads as composed rather than busy. Put a
|
||||||
|
Container inside it; never put page margins on the Section itself.
|
||||||
|
|
||||||
|
`spacing="tight"` for a subordinate band, `spacing="intro"` for a page head, where the air
|
||||||
|
above is full and the air below is reduced so the content follows the title.
|
||||||
|
|
||||||
|
`tone="night"` makes this band a night surface and flips the ink tokens inside it.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Section tone="night" spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Heading>Verifiable, not asserted.</Heading>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro equivalent: `<section class="section section--tight night">`.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { cx } from './cx';
|
||||||
|
|
||||||
|
export interface SectionProps {
|
||||||
|
/** Paper is the default. Night is a dramatic device: the hero and at most one more. */
|
||||||
|
tone?: 'paper' | 'night';
|
||||||
|
/** `tight` for a subordinate band, `intro` for a page head. */
|
||||||
|
spacing?: 'default' | 'tight' | 'intro';
|
||||||
|
id?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One horizontal band of the page, carrying the vertical air around its content. */
|
||||||
|
export function Section({ tone = 'paper', spacing = 'default', id, children }: SectionProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id={id}
|
||||||
|
className={cx(
|
||||||
|
'section',
|
||||||
|
spacing === 'tight' && 'section--tight',
|
||||||
|
spacing === 'intro' && 'section--intro',
|
||||||
|
tone === 'night' && 'night',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
category: Layout
|
||||||
|
---
|
||||||
|
|
||||||
|
# Stack
|
||||||
|
|
||||||
|
Vertical rhythm between blocks. Nothing in this system carries its own margins, so two
|
||||||
|
headings placed next to each other sit flush; Stack is what separates them.
|
||||||
|
|
||||||
|
`gap` is a step on the space scale, 1 to 9. It is never a raw pixel value: hard-coded
|
||||||
|
spacing is a defect in this system, and this component is how you avoid one.
|
||||||
|
|
||||||
|
`align="start"` keeps children at their natural width, which is what a button or a link
|
||||||
|
wants. The default stretches them to the column.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Heading>Four stages. One method.</Heading>
|
||||||
|
<Text measure>Threat analysis, engineering, verification and monitoring.</Text>
|
||||||
|
<Stack gap={4} align="start">
|
||||||
|
<Button href="/contact">Talk to us</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro equivalent: `<div style="display: grid; gap: var(--space-6);">`.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface StackProps {
|
||||||
|
/** A step on the space scale, 1 (tightest) to 9. Defaults to 5. */
|
||||||
|
gap?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
|
||||||
|
/** `start` keeps children at their natural width, which is what buttons want. */
|
||||||
|
align?: 'start' | 'stretch';
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vertical rhythm between blocks, always from the space scale. Never a raw pixel gap. */
|
||||||
|
export function Stack({ gap = 5, align = 'stretch', children }: StackProps) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gap: `var(--space-${gap})`, justifyItems: align === 'start' ? 'start' : undefined }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
category: Foundations
|
||||||
|
---
|
||||||
|
|
||||||
|
# Surface
|
||||||
|
|
||||||
|
The root of every screen. It sets the ground and the base type, so put it outermost and
|
||||||
|
put everything else inside it. Without it a design inherits the canvas defaults and none
|
||||||
|
of CipherCell's typography.
|
||||||
|
|
||||||
|
`tone="paper"` is the base surface for the whole site, a warm paper tone, never pure white.
|
||||||
|
`tone="night"` flips the ink tokens as well as the background, so muted text, hairlines and
|
||||||
|
buttons inside it correct themselves with nothing to set.
|
||||||
|
|
||||||
|
Night is a dramatic device. A page gets the hero and at most one other night band; more than
|
||||||
|
that and the move stops working.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Surface tone="paper">
|
||||||
|
<Section>…</Section>
|
||||||
|
</Surface>
|
||||||
|
```
|
||||||
|
|
||||||
|
In the Astro site this is the `body` element, styled in `src/styles/base.css`.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface SurfaceProps {
|
||||||
|
/** Warm paper, the base surface for the whole site, or the rare night surface. */
|
||||||
|
tone?: 'paper' | 'night';
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The root of every screen: sets the paper or night ground and the base type. */
|
||||||
|
export function Surface({ tone = 'paper', children }: SurfaceProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={tone === 'night' ? 'night' : undefined}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 'var(--step-body)',
|
||||||
|
lineHeight: 'var(--leading-body)',
|
||||||
|
fontWeight: 'var(--weight-regular)',
|
||||||
|
background: tone === 'night' ? undefined : 'var(--paper)',
|
||||||
|
color: tone === 'night' ? undefined : 'var(--ink)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
category: Type
|
||||||
|
---
|
||||||
|
|
||||||
|
# Text
|
||||||
|
|
||||||
|
Body copy and metadata. Set `measure` on every paragraph: it caps the line at 68 characters,
|
||||||
|
and long unmeasured lines are the fastest way to make this layout look careless.
|
||||||
|
|
||||||
|
`muted` is for secondary text and metadata. `size="small"` is for metadata and captions.
|
||||||
|
Neither is a way to fit more in.
|
||||||
|
|
||||||
|
Write short declarative sentences with a full stop. Concrete nouns over categories.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Text measure>Threat models are written once and then run in the pipeline.</Text>
|
||||||
|
<Text size="small" muted>Passau, Innstr. 43</Text>
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro equivalent: `<p class="body measure">`, `<p class="small muted">`.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { cx } from './cx';
|
||||||
|
|
||||||
|
export interface TextProps {
|
||||||
|
size?: 'body' | 'small';
|
||||||
|
/** Secondary text and metadata. */
|
||||||
|
muted?: boolean;
|
||||||
|
/** Caps the line at a readable measure. Use it on every paragraph. */
|
||||||
|
measure?: boolean;
|
||||||
|
as?: 'p' | 'span' | 'div';
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body copy and metadata. Short declarative sentences, with a full stop. */
|
||||||
|
export function Text({ size = 'body', muted, measure, as: Tag = 'p', children }: TextProps) {
|
||||||
|
return (
|
||||||
|
<Tag className={cx(size === 'small' ? 'small' : 'body', muted && 'muted', measure && 'measure')}>{children}</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
category: Controls
|
||||||
|
---
|
||||||
|
|
||||||
|
# TextLink
|
||||||
|
|
||||||
|
An inline or standalone link. The underline carries the signal, and it deepens slightly on
|
||||||
|
hover; there is no colour change, because the system has no accent colour.
|
||||||
|
|
||||||
|
This is the right control for a secondary action next to a Button, and for any link inside
|
||||||
|
a sentence.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<TextLink href="/method">Explore the method</TextLink>
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro equivalent: `<a class="link" href="/method">`.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface TextLinkProps {
|
||||||
|
href: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An underlined link. There is no accent colour; the underline carries the signal. */
|
||||||
|
export function TextLink({ href, children }: TextLinkProps) {
|
||||||
|
return (
|
||||||
|
<a className="link" href={href}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Joins class names, dropping anything falsy. Every class this package emits is
|
||||||
|
// a primitive that already exists in the site's own stylesheet - these
|
||||||
|
// components add no CSS of their own, so the look can never drift from the site.
|
||||||
|
export const cx = (...parts: Array<string | false | null | undefined>): string =>
|
||||||
|
parts.filter(Boolean).join(' ');
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export { Surface } from './Surface';
|
||||||
|
export type { SurfaceProps } from './Surface';
|
||||||
|
export { Stack } from './Stack';
|
||||||
|
export type { StackProps } from './Stack';
|
||||||
|
export { Section } from './Section';
|
||||||
|
export type { SectionProps } from './Section';
|
||||||
|
export { Container } from './Container';
|
||||||
|
export type { ContainerProps } from './Container';
|
||||||
|
export { Grid } from './Grid';
|
||||||
|
export type { GridProps } from './Grid';
|
||||||
|
export { Col } from './Col';
|
||||||
|
export type { ColProps } from './Col';
|
||||||
|
export { Display } from './Display';
|
||||||
|
export type { DisplayProps } from './Display';
|
||||||
|
export { Heading } from './Heading';
|
||||||
|
export type { HeadingProps } from './Heading';
|
||||||
|
export { Text } from './Text';
|
||||||
|
export type { TextProps } from './Text';
|
||||||
|
export { Eyebrow } from './Eyebrow';
|
||||||
|
export type { EyebrowProps } from './Eyebrow';
|
||||||
|
export { Button } from './Button';
|
||||||
|
export type { ButtonProps } from './Button';
|
||||||
|
export { TextLink } from './TextLink';
|
||||||
|
export type { TextLinkProps } from './TextLink';
|
||||||
|
export { Rule } from './Rule';
|
||||||
|
export type { RuleProps } from './Rule';
|
||||||
|
export { Prose } from './Prose';
|
||||||
|
export type { ProseProps } from './Prose';
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": true,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"outDir": "../ds-types"
|
||||||
|
},
|
||||||
|
"include": ["*.ts", "*.tsx"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
/* tokens.css inlined by .design-sync/make-ds-styles.mjs */
|
||||||
|
/* Design tokens. The only place where colours, sizes and durations are defined.
|
||||||
|
Components consume these; a hard-coded hex or px value in a component is a defect. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Colour: paper, night, image. Nothing else. */
|
||||||
|
--paper: #E9E5DF;
|
||||||
|
--paper-deep: #DFDAD1;
|
||||||
|
--ink: #1A1A18;
|
||||||
|
--ink-muted: #57564F;
|
||||||
|
--night: #0E100F;
|
||||||
|
--line: rgb(26 26 24 / 0.14);
|
||||||
|
|
||||||
|
/* Immutable copies for resetting inside a .night or overlay context */
|
||||||
|
--ink-base: #1A1A18;
|
||||||
|
--ink-muted-base: #57564F;
|
||||||
|
--line-base: rgb(26 26 24 / 0.14);
|
||||||
|
|
||||||
|
/* Counterparts on night surfaces */
|
||||||
|
--ink-on-night: var(--paper);
|
||||||
|
--ink-muted-on-night: rgb(233 229 223 / 0.62);
|
||||||
|
--line-on-night: rgb(233 229 223 / 0.18);
|
||||||
|
|
||||||
|
/* Image overlay */
|
||||||
|
--overlay: linear-gradient(to top, rgb(14 16 15 / 0.75), rgb(14 16 15 / 0.2));
|
||||||
|
|
||||||
|
/* Type */
|
||||||
|
--font-sans: 'Inter Tight', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
|
||||||
|
--step-display: clamp(2.75rem, 1.2rem + 6.2vw, 7rem);
|
||||||
|
--step-h2: clamp(1.9rem, 1.1rem + 2.6vw, 3.25rem);
|
||||||
|
--step-h3: clamp(1.25rem, 1.05rem + 0.7vw, 1.6rem);
|
||||||
|
/* Card and footer titles: between h2 and h3, for headings that name rather than claim. */
|
||||||
|
--step-title: clamp(1.6rem, 1.2rem + 1.5vw, 2.5rem);
|
||||||
|
--step-body: clamp(1rem, 0.96rem + 0.2vw, 1.125rem);
|
||||||
|
--step-small: 0.875rem;
|
||||||
|
|
||||||
|
--weight-regular: 400;
|
||||||
|
--weight-medium: 500;
|
||||||
|
|
||||||
|
--track-display: -0.03em;
|
||||||
|
--track-h2: -0.02em;
|
||||||
|
--track-h3: -0.01em;
|
||||||
|
|
||||||
|
--leading-display: 1.02;
|
||||||
|
--leading-h2: 1.08;
|
||||||
|
--leading-h3: 1.2;
|
||||||
|
--leading-title: 1.1;
|
||||||
|
--leading-body: 1.55;
|
||||||
|
--leading-small: 1.4;
|
||||||
|
|
||||||
|
--measure: 34em;
|
||||||
|
|
||||||
|
/* Space */
|
||||||
|
--space-1: 0.25rem;
|
||||||
|
--space-2: 0.5rem;
|
||||||
|
--space-3: 0.75rem;
|
||||||
|
--space-4: 1rem;
|
||||||
|
--space-5: 1.5rem;
|
||||||
|
--space-6: 2rem;
|
||||||
|
--space-7: 3rem;
|
||||||
|
--space-8: 4rem;
|
||||||
|
--space-9: 6rem;
|
||||||
|
|
||||||
|
--section: clamp(6rem, 12vw, 11rem);
|
||||||
|
--gutter: clamp(1.25rem, 5vw, 5rem);
|
||||||
|
--gap: clamp(1rem, 2vw, 2rem);
|
||||||
|
--container: 90rem; /* 1440px */
|
||||||
|
|
||||||
|
/* Shape */
|
||||||
|
--hairline: 1px;
|
||||||
|
--radius: 0;
|
||||||
|
--radius-sm: 2px;
|
||||||
|
|
||||||
|
/* Motion */
|
||||||
|
--ease: cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
--dur-fast: 180ms;
|
||||||
|
--dur: 240ms;
|
||||||
|
--dur-slow: 320ms;
|
||||||
|
--dur-hero: 1400ms;
|
||||||
|
|
||||||
|
/* Chrome */
|
||||||
|
--header-height: 4.5rem;
|
||||||
|
|
||||||
|
/* Focus */
|
||||||
|
--focus-width: 2px;
|
||||||
|
--focus-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Self-hosted Inter Tight, variable, latin + latin-ext. */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter Tight';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../public/fonts/inter-tight-latin-wght-normal.woff2') format('woff2-variations');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter Tight';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../public/fonts/inter-tight-latin-ext-wght-normal.woff2') format('woff2-variations');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
* { margin: 0; }
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
[id] { scroll-margin-top: var(--header-height); }
|
||||||
|
body {
|
||||||
|
min-height: 100dvh;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--step-body);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
font-weight: var(--weight-regular);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
font-feature-settings: 'ss01' off, 'cv11' on;
|
||||||
|
}
|
||||||
|
img, picture, svg, video { display: block; max-width: 100%; }
|
||||||
|
img { height: auto; }
|
||||||
|
input, button, textarea, select { font: inherit; color: inherit; }
|
||||||
|
button { background: none; border: 0; padding: 0; cursor: pointer; }
|
||||||
|
ul[role='list'], ol[role='list'] { list-style: none; padding: 0; }
|
||||||
|
a { color: inherit; text-decoration-color: currentColor; text-underline-offset: 0.14em; text-decoration-thickness: from-font; }
|
||||||
|
h1, h2, h3, h4 { font-weight: var(--weight-medium); overflow-wrap: break-word; }
|
||||||
|
p { overflow-wrap: break-word; }
|
||||||
|
|
||||||
|
/* Focus: one ring, everywhere. */
|
||||||
|
:focus-visible {
|
||||||
|
outline: var(--focus-width) solid currentColor;
|
||||||
|
outline-offset: var(--focus-offset);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
:focus:not(:focus-visible) { outline: none; }
|
||||||
|
|
||||||
|
/* Type scale */
|
||||||
|
.display {
|
||||||
|
font-size: var(--step-display);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--track-display);
|
||||||
|
line-height: var(--leading-display);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
.h2 {
|
||||||
|
font-size: var(--step-h2);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--track-h2);
|
||||||
|
line-height: var(--leading-h2);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
.h3 {
|
||||||
|
font-size: var(--step-h3);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--track-h3);
|
||||||
|
line-height: var(--leading-h3);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
.body { font-size: var(--step-body); line-height: var(--leading-body); }
|
||||||
|
.small {
|
||||||
|
font-size: var(--step-small);
|
||||||
|
line-height: var(--leading-small);
|
||||||
|
}
|
||||||
|
.muted { color: var(--ink-muted); }
|
||||||
|
|
||||||
|
/* An eyebrow is sentence case, muted, small. Never all caps. */
|
||||||
|
.eyebrow {
|
||||||
|
font-size: var(--step-small);
|
||||||
|
line-height: var(--leading-small);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-weight: var(--weight-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primitives */
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--container);
|
||||||
|
margin-inline: auto;
|
||||||
|
padding-inline: var(--gutter);
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||||
|
column-gap: var(--gap);
|
||||||
|
row-gap: var(--space-7);
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
padding-block: var(--section);
|
||||||
|
}
|
||||||
|
.section--tight { padding-block: calc(var(--section) * 0.6); }
|
||||||
|
/* A page head: full air above, less below, so the content follows the title. */
|
||||||
|
.section--intro { padding-block: var(--section) calc(var(--section) * 0.4); }
|
||||||
|
.measure { max-width: var(--measure); }
|
||||||
|
|
||||||
|
/* Night surface */
|
||||||
|
.night {
|
||||||
|
background: var(--night);
|
||||||
|
color: var(--ink-on-night);
|
||||||
|
--ink: var(--ink-on-night);
|
||||||
|
--ink-muted: var(--ink-muted-on-night);
|
||||||
|
--line: var(--line-on-night);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hairline rules carry information: they separate, they don't decorate. */
|
||||||
|
.rule { border: 0; border-top: var(--hairline) solid var(--line); }
|
||||||
|
|
||||||
|
/* Links and buttons work through underline, weight and surface. */
|
||||||
|
.link {
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: var(--hairline);
|
||||||
|
text-underline-offset: 0.2em;
|
||||||
|
transition: text-underline-offset var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
.link:hover { text-underline-offset: 0.3em; }
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: var(--space-3) var(--space-5);
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--paper);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: var(--leading-small);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
transition: background var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
.button:hover { background: var(--ink-muted); }
|
||||||
|
.button--small { padding: var(--space-2) var(--space-4); font-size: var(--step-small); }
|
||||||
|
.night .button { background: var(--paper); color: var(--night); }
|
||||||
|
.night .button:hover { background: var(--paper-deep); }
|
||||||
|
|
||||||
|
/* Prose: long-form text */
|
||||||
|
.prose { max-width: var(--measure); }
|
||||||
|
.prose > * + * { margin-top: var(--space-5); }
|
||||||
|
.prose h2 {
|
||||||
|
font-size: var(--step-h3);
|
||||||
|
letter-spacing: var(--track-h3);
|
||||||
|
line-height: var(--leading-h3);
|
||||||
|
margin-top: var(--space-8);
|
||||||
|
}
|
||||||
|
.prose h3 { font-size: var(--step-body); margin-top: var(--space-7); }
|
||||||
|
.prose ul, .prose ol { padding-left: 1.2em; }
|
||||||
|
.prose li + li { margin-top: var(--space-3); }
|
||||||
|
.prose a { text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.2em; }
|
||||||
|
.prose strong { font-weight: var(--weight-medium); }
|
||||||
|
.prose blockquote {
|
||||||
|
padding-left: var(--space-5);
|
||||||
|
border-left: var(--hairline) solid var(--line);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
.prose img { width: 100%; }
|
||||||
|
.prose figcaption { margin-top: var(--space-3); font-size: var(--step-small); color: var(--ink-muted); }
|
||||||
|
|
||||||
|
/* Images */
|
||||||
|
.img-cover { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.img-doc { filter: saturate(0.55) contrast(1.02); }
|
||||||
|
.img-mono { filter: grayscale(1) contrast(1.04); }
|
||||||
|
/* Portraits were shot on light grey; multiply lets the paper carry them like a print. */
|
||||||
|
.img-print { mix-blend-mode: multiply; }
|
||||||
|
|
||||||
|
/* Skip link */
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
left: var(--gutter);
|
||||||
|
top: var(--space-3);
|
||||||
|
transform: translateY(-200%);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--paper);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.skip-link:focus { transform: none; }
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute !important;
|
||||||
|
width: 1px; height: 1px;
|
||||||
|
padding: 0; margin: -1px;
|
||||||
|
overflow: hidden; clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap; border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Motion off. No exceptions. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html { scroll-behavior: auto; }
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
export interface ButtonProps {
|
||||||
|
/** Renders an anchor when set, a button element otherwise. */
|
||||||
|
href?: string;
|
||||||
|
size?: 'default' | 'small';
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
/** A filled action. Its label names the action: "Talk to us", never "Learn more". */
|
||||||
|
export declare function Button({ href, size, children }: ButtonProps): import("react").JSX.Element;
|
||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
export interface ColProps {
|
||||||
|
/** How many of the twelve columns this block occupies. */
|
||||||
|
span?: number;
|
||||||
|
/** The column it starts at, counting from 1. Offsets are how asymmetry is made. */
|
||||||
|
start?: number;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
/** One block on the grid. A headline takes 7 or 8 columns, its body copy 4 or 5. */
|
||||||
|
export declare function Col({ span, start, children }: ColProps): import("react").JSX.Element;
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
export interface ContainerProps {
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
/** Centres content to the page width and holds the page margins. */
|
||||||
|
export declare function Container({ children }: ContainerProps): import("react").JSX.Element;
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
export interface DisplayProps {
|
||||||
|
/** h1 by default. One h1 per page. */
|
||||||
|
as?: 'h1' | 'h2' | 'p';
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
/** The largest type on the page. Break its lines by hand; it is a composed object. */
|
||||||
|
export declare function Display({ as: Tag, children }: DisplayProps): import("react").JSX.Element;
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
export interface EyebrowProps {
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
/** A label above a headline, in sentence case. Never all caps, and never decorative. */
|
||||||
|
export declare function Eyebrow({ children }: EyebrowProps): import("react").JSX.Element;
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
export interface GridProps {
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
/** The twelve-column grid. Place children with Col, asymmetrically. */
|
||||||
|
export declare function Grid({ children }: GridProps): import("react").JSX.Element;
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
export interface HeadingProps {
|
||||||
|
/** 2 for a section headline, 3 for a block within it. Never skip a level. */
|
||||||
|
level?: 2 | 3;
|
||||||
|
id?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
/** A section headline. It may state a claim, but it never advertises. */
|
||||||
|
export declare function Heading({ level, id, children }: HeadingProps): import("react").JSX.Element;
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
export interface ProseProps {
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
/** Long-form text: an insight, a legal page. Styles the headings and lists inside it. */
|
||||||
|
export declare function Prose({ children }: ProseProps): import("react").JSX.Element;
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
export interface RuleProps {
|
||||||
|
/** Nothing. A rule separates; it never decorates. */
|
||||||
|
children?: never;
|
||||||
|
}
|
||||||
|
/** A hairline. Use it only where it carries information. */
|
||||||
|
export declare function Rule(_props: RuleProps): import("react").JSX.Element;
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
export interface SectionProps {
|
||||||
|
/** Paper is the default. Night is a dramatic device: the hero and at most one more. */
|
||||||
|
tone?: 'paper' | 'night';
|
||||||
|
/** `tight` for a subordinate band, `intro` for a page head. */
|
||||||
|
spacing?: 'default' | 'tight' | 'intro';
|
||||||
|
id?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
/** One horizontal band of the page, carrying the vertical air around its content. */
|
||||||
|
export declare function Section({ tone, spacing, id, children }: SectionProps): import("react").JSX.Element;
|
||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
export interface StackProps {
|
||||||
|
/** A step on the space scale, 1 (tightest) to 9. Defaults to 5. */
|
||||||
|
gap?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
|
||||||
|
/** `start` keeps children at their natural width, which is what buttons want. */
|
||||||
|
align?: 'start' | 'stretch';
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
/** Vertical rhythm between blocks, always from the space scale. Never a raw pixel gap. */
|
||||||
|
export declare function Stack({ gap, align, children }: StackProps): import("react").JSX.Element;
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
export interface SurfaceProps {
|
||||||
|
/** Warm paper, the base surface for the whole site, or the rare night surface. */
|
||||||
|
tone?: 'paper' | 'night';
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
/** The root of every screen: sets the paper or night ground and the base type. */
|
||||||
|
export declare function Surface({ tone, children }: SurfaceProps): import("react").JSX.Element;
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
export interface TextProps {
|
||||||
|
size?: 'body' | 'small';
|
||||||
|
/** Secondary text and metadata. */
|
||||||
|
muted?: boolean;
|
||||||
|
/** Caps the line at a readable measure. Use it on every paragraph. */
|
||||||
|
measure?: boolean;
|
||||||
|
as?: 'p' | 'span' | 'div';
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
/** Body copy and metadata. Short declarative sentences, with a full stop. */
|
||||||
|
export declare function Text({ size, muted, measure, as: Tag, children }: TextProps): import("react").JSX.Element;
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
export interface TextLinkProps {
|
||||||
|
href: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
/** An underlined link. There is no accent colour; the underline carries the signal. */
|
||||||
|
export declare function TextLink({ href, children }: TextLinkProps): import("react").JSX.Element;
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
export declare const cx: (...parts: Array<string | false | null | undefined>) => string;
|
||||||
Vendored
+28
@@ -0,0 +1,28 @@
|
|||||||
|
export { Surface } from './Surface';
|
||||||
|
export type { SurfaceProps } from './Surface';
|
||||||
|
export { Stack } from './Stack';
|
||||||
|
export type { StackProps } from './Stack';
|
||||||
|
export { Section } from './Section';
|
||||||
|
export type { SectionProps } from './Section';
|
||||||
|
export { Container } from './Container';
|
||||||
|
export type { ContainerProps } from './Container';
|
||||||
|
export { Grid } from './Grid';
|
||||||
|
export type { GridProps } from './Grid';
|
||||||
|
export { Col } from './Col';
|
||||||
|
export type { ColProps } from './Col';
|
||||||
|
export { Display } from './Display';
|
||||||
|
export type { DisplayProps } from './Display';
|
||||||
|
export { Heading } from './Heading';
|
||||||
|
export type { HeadingProps } from './Heading';
|
||||||
|
export { Text } from './Text';
|
||||||
|
export type { TextProps } from './Text';
|
||||||
|
export { Eyebrow } from './Eyebrow';
|
||||||
|
export type { EyebrowProps } from './Eyebrow';
|
||||||
|
export { Button } from './Button';
|
||||||
|
export type { ButtonProps } from './Button';
|
||||||
|
export { TextLink } from './TextLink';
|
||||||
|
export type { TextLinkProps } from './TextLink';
|
||||||
|
export { Rule } from './Rule';
|
||||||
|
export type { RuleProps } from './Rule';
|
||||||
|
export { Prose } from './Prose';
|
||||||
|
export type { ProseProps } from './Prose';
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Derives the two files the converter reads that cannot be the site's own:
|
||||||
|
// the design-system stylesheet, and the guideline copy of the brief.
|
||||||
|
//
|
||||||
|
// The stylesheet comes from src/styles/base.css. Two things have to change, and nothing else:
|
||||||
|
// 1. `@import './tokens.css'` is replaced by the token file's contents. The bundle is
|
||||||
|
// copied verbatim, so an @import pointing outside it would dangle and every
|
||||||
|
// var(--token) in the sheet would resolve to nothing.
|
||||||
|
// 2. `url('/fonts/...')` is absolute because the site serves fonts from the web root;
|
||||||
|
// rewritten relative to this directory so the converter can resolve and copy them.
|
||||||
|
//
|
||||||
|
// The guideline copy is docs/design-brief.md, a verbatim copy of CLAUDE.md. The converter
|
||||||
|
// names each uploaded guideline after its source file, and claude.ai/design refuses any
|
||||||
|
// path called CLAUDE.md, so the brief cannot be shipped under its own name.
|
||||||
|
//
|
||||||
|
// Run it before the converter: node .design-sync/make-ds-styles.mjs
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const tokens = readFileSync(join(root, 'src/styles/tokens.css'), 'utf8');
|
||||||
|
const base = readFileSync(join(root, 'src/styles/base.css'), 'utf8');
|
||||||
|
|
||||||
|
const importRx = /@import\s+['"]\.\/tokens\.css['"]\s*;/;
|
||||||
|
if (!importRx.test(base)) throw new Error("src/styles/base.css no longer @imports './tokens.css' - update this script");
|
||||||
|
|
||||||
|
const out = base
|
||||||
|
.replace(importRx, `/* tokens.css inlined by .design-sync/make-ds-styles.mjs */\n${tokens.trim()}`)
|
||||||
|
.replace(/url\('\/fonts\//g, "url('../public/fonts/");
|
||||||
|
|
||||||
|
writeFileSync(join(root, '.design-sync/ds-styles.css'), out);
|
||||||
|
console.log(`wrote .design-sync/ds-styles.css (${out.length} bytes, tokens inlined)`);
|
||||||
|
|
||||||
|
const brief = readFileSync(join(root, 'CLAUDE.md'), 'utf8');
|
||||||
|
const banner =
|
||||||
|
'<!-- Generated from CLAUDE.md by .design-sync/make-ds-styles.mjs. Edit CLAUDE.md, not this file. -->\n\n';
|
||||||
|
mkdirSync(join(root, 'docs'), { recursive: true });
|
||||||
|
writeFileSync(join(root, 'docs/design-brief.md'), banner + brief);
|
||||||
|
console.log(`wrote docs/design-brief.md (${brief.length} bytes, copied from CLAUDE.md)`);
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// forked from design-sync lib/css.mjs - cpSync of font files fails with EACCES on this
|
||||||
|
// repo's virtiofs mount; read+write copies the same bytes and works.
|
||||||
|
// CSS handling: token-file copy, @font-face extraction, and the final
|
||||||
|
// styles.css writer (the styles entry point - an @import list, never inlined CSS).
|
||||||
|
// Storybook-only fallbacks live in css-fallback.mjs.
|
||||||
|
|
||||||
|
import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
||||||
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
|
import { ls } from '../../.ds-sync/lib/common.mjs';
|
||||||
|
|
||||||
|
// Parse @font-face blocks from `cssPath` -> resolve url() paths relative to
|
||||||
|
// `srcDir` -> copy .woff2/.woff/.ttf/.otf to fonts/ -> return rewritten rules.
|
||||||
|
// `roots` bounds the resolved path so a `url(../../etc/passwd)` can't escape -
|
||||||
|
// one or more directories the font file may legitimately be under.
|
||||||
|
export function extractFonts(cssPath, srcDir, { fontsOut, roots }) {
|
||||||
|
// Bounds and targets are REALPATHED (a font-named symlink inside the
|
||||||
|
// workspace pointing outside must not smuggle an arbitrary file into the
|
||||||
|
// uploadable fonts/ - same containment rule as cfg.extraFonts), and the
|
||||||
|
// check is relative()-based, not a startsWith prefix: case-insensitive on
|
||||||
|
// win32 where a pnpm junction can realpath to canonical D:\ while a
|
||||||
|
// symlink-free root keeps user-typed d:\ (failure direction of the prefix
|
||||||
|
// form is false-rejection - legit brand fonts silently skipped).
|
||||||
|
const realOf = (p) => { try { return realpathSync(p); } catch { return null; } };
|
||||||
|
const rootsReal = (Array.isArray(roots) ? roots : [roots]).map((r) => realOf(resolve(r)) ?? resolve(r));
|
||||||
|
const insideRoots = (p) => rootsReal.some((root) => {
|
||||||
|
const rel = relative(root, p);
|
||||||
|
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
||||||
|
});
|
||||||
|
if (!existsSync(cssPath)) return [];
|
||||||
|
const css = readFileSync(cssPath, 'utf8');
|
||||||
|
const rules = [];
|
||||||
|
for (const m of css.matchAll(/@font-face\s*\{([^}]+)\}/g)) {
|
||||||
|
const body = m[1];
|
||||||
|
const fam = body.match(/font-family\s*:\s*['"]?([^;'"\n]+)['"]?/)?.[1]?.trim();
|
||||||
|
const urls = [...body.matchAll(/url\(\s*['"]?([^'")]+?\.(?:woff2?|ttf|otf))['"]?\s*\)/gi)].map((u) => u[1]);
|
||||||
|
if (!fam || !urls.length) continue;
|
||||||
|
let rewritten = body;
|
||||||
|
for (const u of urls) {
|
||||||
|
if (/^(https?:|data:)/.test(u)) continue; // CDN / inline - leave as-is
|
||||||
|
const src = resolve(srcDir, u.replace(/^\.\//, ''));
|
||||||
|
const real = realOf(src);
|
||||||
|
if (!real || !insideRoots(real)) continue;
|
||||||
|
const name = basename(src);
|
||||||
|
mkdirSync(fontsOut, { recursive: true });
|
||||||
|
// cpSync trips EACCES on this mount; a plain read+write copies the same bytes.
|
||||||
|
writeFileSync(join(fontsOut, name), readFileSync(real));
|
||||||
|
rewritten = rewritten.split(u).join(`./${name}`);
|
||||||
|
}
|
||||||
|
rules.push(`@font-face{${rewritten}}`);
|
||||||
|
}
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy a tokens package's shipped CSS verbatim into OUT/tokens/. tokensGlob
|
||||||
|
// supports a single trailing `**` segment for deep recursion.
|
||||||
|
export function copyTokens({ tokensPkg, tokensGlob, nodeModules, out }) {
|
||||||
|
const tokenFiles = [];
|
||||||
|
if (!tokensPkg) return tokenFiles;
|
||||||
|
const tdir = join(nodeModules, tokensPkg);
|
||||||
|
const tjson = JSON.parse(readFileSync(join(tdir, 'package.json'), 'utf8'));
|
||||||
|
if (tokensGlob) {
|
||||||
|
const parts = tokensGlob.split('/');
|
||||||
|
const pat = parts.pop();
|
||||||
|
const rx = new RegExp('^' + pat.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
|
||||||
|
const deep = parts.includes('**');
|
||||||
|
const base = join(tdir, ...parts.filter((p) => p !== '**'));
|
||||||
|
(function collect(d, rel = '') {
|
||||||
|
if (!existsSync(d)) return;
|
||||||
|
for (const e of ls(d, { withFileTypes: true })) {
|
||||||
|
const r = rel ? `${rel}/${e.name}` : e.name;
|
||||||
|
if (e.isDirectory() && deep) collect(join(d, e.name), r);
|
||||||
|
else if (e.isFile() && rx.test(e.name)) {
|
||||||
|
// Preserve subdir structure so @import './sub/x.css' inside a
|
||||||
|
// copied file keeps resolving.
|
||||||
|
mkdirSync(dirname(join(out, 'tokens', r)), { recursive: true });
|
||||||
|
cpSync(join(d, e.name), join(out, 'tokens', r));
|
||||||
|
tokenFiles.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})(base);
|
||||||
|
} else {
|
||||||
|
for (const sub of ['dist/css', 'css', 'dist', '.']) {
|
||||||
|
const d = join(tdir, sub);
|
||||||
|
if (!existsSync(d)) continue;
|
||||||
|
for (const f of ls(d)) {
|
||||||
|
if (f.endsWith('.css')) {
|
||||||
|
cpSync(join(d, f), join(out, 'tokens', f));
|
||||||
|
tokenFiles.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tokenFiles.length) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error(` tokens: ${tokenFiles.length} files from ${tokensPkg}@${tjson.version}`);
|
||||||
|
return tokenFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// _ds_bundle.css enters the styles.css closure (rendered designs load it),
|
||||||
|
// so its @font-face blocks must not carry package-relative url()s: the font
|
||||||
|
// binaries aren't uploaded at those paths, and a dead-src face declared
|
||||||
|
// AFTER fonts/fonts.css shadows the working copy of the same family
|
||||||
|
// (browsers don't fall back to an earlier duplicate face) - brand fonts
|
||||||
|
// silently degrade to system fonts. Rewrite urls to the fonts/ copies
|
||||||
|
// extractFonts made (matched by basename, same flattening); drop any face
|
||||||
|
// still referencing an unresolvable relative url (a dead src is worse than
|
||||||
|
// no face - and it recurs as an app-side error on every compile).
|
||||||
|
export function rewriteBundleFontFaces({ out, bundleCss }) {
|
||||||
|
const p = bundleCss ?? join(out, '_ds_bundle.css');
|
||||||
|
let css;
|
||||||
|
try { css = readFileSync(p, 'utf8'); } catch { return; }
|
||||||
|
if (!/@font-face/i.test(css)) return;
|
||||||
|
let dropped = 0, rewrote = 0;
|
||||||
|
const next = css.replace(/@font-face\s*\{[^}]*\}/gi, (block) => {
|
||||||
|
let b = block;
|
||||||
|
for (const m of block.matchAll(/url\(\s*['"]?([^'")]+)['"]?\s*\)/gi)) {
|
||||||
|
const u = m[1];
|
||||||
|
if (/^(?:https?:|data:|\.\/fonts\/)/.test(u)) continue;
|
||||||
|
const name = basename(u.split(/[?#]/)[0]);
|
||||||
|
if (existsSync(join(out, 'fonts', name))) { b = b.split(u).join(`./fonts/${name}`); rewrote++; }
|
||||||
|
}
|
||||||
|
if (/url\(\s*['"]?(?!https?:|data:|\.\/fonts\/)/i.test(b)) { dropped++; return '/* @ds-font-face-dropped: unresolvable src */'; }
|
||||||
|
return b;
|
||||||
|
});
|
||||||
|
if (rewrote || dropped) {
|
||||||
|
writeFileSync(p, next);
|
||||||
|
console.error(` _ds_bundle.css fonts: ${rewrote} url(s) rewritten to fonts/${dropped ? `, ${dropped} dead @font-face block(s) dropped` : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// styles.css - the styles entry point. The claude.ai/design app's
|
||||||
|
// contract: rendered designs consume ONLY this file's transitive @import
|
||||||
|
// closure (plus the JS bundle) - `_ds_bundle.css` is not loaded by anything
|
||||||
|
// app-side, so component CSS left out of this closure never reaches a design
|
||||||
|
// built with the DS (the DS-pane cards link it directly, which masks the
|
||||||
|
// gap). Import it LAST, after tokens/fonts. Token pollution: the app's
|
||||||
|
// scope filter is a permissive heuristic - :root/theme containers, but also
|
||||||
|
// single lowercase class selectors (`.btn { --btn-pad: ... }`) and data-attr
|
||||||
|
// selectors register as token scopes - so public component vars from the
|
||||||
|
// bundle DO enter the token list. That's tolerable (they're real, usable
|
||||||
|
// vars) and the price of designs actually receiving component CSS.
|
||||||
|
export function writeStylesCss({ out, tokenFiles, bundleCss, fontRules, remoteImports }) {
|
||||||
|
let hasBundleCss = false;
|
||||||
|
try {
|
||||||
|
// The CSS-in-JS placeholder (@ds-css-runtime) isn't real CSS - importing
|
||||||
|
// it would also suppress the [CSS_RUNTIME] message below.
|
||||||
|
const css = readFileSync(bundleCss ?? join(out, '_ds_bundle.css'), 'utf8');
|
||||||
|
hasBundleCss = css.trim().length > 0 && !css.startsWith('/* @ds-css-runtime');
|
||||||
|
} catch { /* absent */ }
|
||||||
|
const styleImports = [
|
||||||
|
...tokenFiles.map((f) => `@import "./tokens/${f}";`),
|
||||||
|
...(fontRules.length ? ['@import "./fonts/fonts.css";'] : []),
|
||||||
|
...remoteImports.map((u) => `@import url("${u}");`),
|
||||||
|
...(hasBundleCss ? ['@import "./_ds_bundle.css";'] : []),
|
||||||
|
];
|
||||||
|
if (styleImports.length) {
|
||||||
|
writeFileSync(join(out, 'styles.css'), styleImports.join('\n') + '\n');
|
||||||
|
console.error(` styles.css: ${styleImports.length} @import(s)${hasBundleCss ? ' (incl. _ds_bundle.css \u2014 component styles ship to designs via this closure)' : ''}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeFileSync(
|
||||||
|
join(out, 'styles.css'),
|
||||||
|
'/* @ds-styles: runtime \u2014 this design system injects its styles at runtime (CSS-in-JS); no static stylesheet to import. */\n',
|
||||||
|
);
|
||||||
|
console.error('[CSS_RUNTIME] no static CSS found (tokens/component/fonts/remote all empty) \u2014 wrote a self-styling styles.css. Expected for CSS-in-JS DSes; if this DS does ship a stylesheet, set cfg.cssEntry to it. If cfg.cssEntry is ALREADY set and renders verify, this line refers only to the scrape \u2014 do not chase it.');
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Surface, Section, Container, Stack, Button, TextLink, Text } from 'ciphercell-homepage';
|
||||||
|
|
||||||
|
export const Primary = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Stack gap={5} align="start">
|
||||||
|
<Button href="/contact">Talk to us</Button>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const WithSecondary = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Stack gap={4} align="start">
|
||||||
|
<Button href="/cipherverifier">Explore CipherVerifier</Button>
|
||||||
|
<TextLink href="/method">Explore the method</TextLink>
|
||||||
|
<Text size="small" muted>A second action is a link, never a second button.</Text>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Small = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Stack gap={5} align="start">
|
||||||
|
<Button size="small">Copy the report</Button>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const OnNight = () => (
|
||||||
|
<Surface tone="night">
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Stack gap={4} align="start">
|
||||||
|
<Button href="/contact">Talk to us</Button>
|
||||||
|
<Text size="small" muted>On a night surface the button inverts itself.</Text>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Surface, Section, Container, Grid, Col, Stack, Heading, Text } from 'ciphercell-homepage';
|
||||||
|
|
||||||
|
export const Offset = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Grid>
|
||||||
|
<Col span={8}>
|
||||||
|
<Heading>Four stages. One method.</Heading>
|
||||||
|
</Col>
|
||||||
|
<Col span={5} start={8}>
|
||||||
|
<Text measure>
|
||||||
|
Threat analysis, engineering, verification and monitoring run alongside your development.
|
||||||
|
</Text>
|
||||||
|
</Col>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FullWidth = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Grid>
|
||||||
|
<Col>
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Heading level={3}>A block across all twelve columns.</Heading>
|
||||||
|
<Text muted>Twelve is the default, so span may be left out.</Text>
|
||||||
|
</Stack>
|
||||||
|
</Col>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Surface, Section, Container, Stack, Heading, Text, Rule } from 'ciphercell-homepage';
|
||||||
|
|
||||||
|
export const Default = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Stack gap={5}>
|
||||||
|
<Heading level={3}>Talk to us.</Heading>
|
||||||
|
<Text measure muted>
|
||||||
|
The page margins and the maximum page width both come from the Container.
|
||||||
|
</Text>
|
||||||
|
<Rule />
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Surface, Section, Container, Stack, Display, Text } from 'ciphercell-homepage';
|
||||||
|
|
||||||
|
export const Hero = () => (
|
||||||
|
<Surface tone="night">
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Display>
|
||||||
|
Security for the software<br />everything else runs on.
|
||||||
|
</Display>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const OnPaper = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Stack gap={5}>
|
||||||
|
<Display as="p">CipherVerifier.<br />Every finding, verified.</Display>
|
||||||
|
<Text muted>Set as a paragraph, because the page heading is elsewhere.</Text>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Surface, Section, Container, Stack, Eyebrow, Heading, Text } from 'ciphercell-homepage';
|
||||||
|
|
||||||
|
export const AboveHeadline = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Stack gap={3}>
|
||||||
|
<Eyebrow>Insights</Eyebrow>
|
||||||
|
<Heading>What a verification report owes you.</Heading>
|
||||||
|
<Text measure muted>
|
||||||
|
The eyebrow names the section. It is sentence case, and never set in all caps.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Surface, Section, Container, Grid, Col, Stack, Heading, Text, TextLink } from 'ciphercell-homepage';
|
||||||
|
|
||||||
|
export const Asymmetric = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Grid>
|
||||||
|
<Col span={7}>
|
||||||
|
<Heading>Security is built in,<br />not bolted on.</Heading>
|
||||||
|
</Col>
|
||||||
|
<Col span={4} start={9}>
|
||||||
|
<Stack gap={5} align="start">
|
||||||
|
<Text measure>
|
||||||
|
We model real adversaries and build zero trust into the architecture rather than
|
||||||
|
around it.
|
||||||
|
</Text>
|
||||||
|
<TextLink href="/about">About CipherCell</TextLink>
|
||||||
|
</Stack>
|
||||||
|
</Col>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Thirds = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Grid>
|
||||||
|
<Col span={4}>
|
||||||
|
<Stack gap={3}>
|
||||||
|
<Heading level={3}>Private 5G</Heading>
|
||||||
|
<Text size="small" muted>Open interfaces, many vendors.</Text>
|
||||||
|
</Stack>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Stack gap={3}>
|
||||||
|
<Heading level={3}>Critical networks</Heading>
|
||||||
|
<Text size="small" muted>Where a breach stops production.</Text>
|
||||||
|
</Stack>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Stack gap={3}>
|
||||||
|
<Heading level={3}>Cloud-native</Heading>
|
||||||
|
<Text size="small" muted>Security that runs as code.</Text>
|
||||||
|
</Stack>
|
||||||
|
</Col>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Surface, Section, Container, Stack, Heading, Text } from 'ciphercell-homepage';
|
||||||
|
|
||||||
|
export const SectionHeadline = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Heading>Verifiable, not asserted.</Heading>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Levels = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Heading>Four stages. One method.</Heading>
|
||||||
|
<Stack gap={3}>
|
||||||
|
<Heading level={3}>Threat analysis</Heading>
|
||||||
|
<Text measure muted>
|
||||||
|
Automated analysis of real attack paths, running inside your pipeline.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={3}>
|
||||||
|
<Heading level={3}>Verification</Heading>
|
||||||
|
<Text measure muted>
|
||||||
|
Independent review of code and security measures, hardening and testing.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Surface, Section, Container, Prose } from 'ciphercell-homepage';
|
||||||
|
|
||||||
|
export const Insight = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Prose>
|
||||||
|
<h2>What a verification report owes you</h2>
|
||||||
|
<p>
|
||||||
|
A finding without its reasoning is an opinion. Every verdict in a CipherVerifier report
|
||||||
|
carries the file, the line and the argument that produced it.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>The analyser that raised it</li>
|
||||||
|
<li>The verdict, and how certain it is</li>
|
||||||
|
<li>The fix we would ship</li>
|
||||||
|
</ul>
|
||||||
|
<blockquote>Verifiable, not asserted.</blockquote>
|
||||||
|
</Prose>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Surface, Section, Container, Stack, Rule, Heading, Text } from 'ciphercell-homepage';
|
||||||
|
|
||||||
|
export const BetweenItems = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Stack gap={5}>
|
||||||
|
<Stack gap={3}>
|
||||||
|
<Heading level={3}>Threat analysis</Heading>
|
||||||
|
<Text size="small" muted>Threat model, attack paths, CI/CD integration.</Text>
|
||||||
|
</Stack>
|
||||||
|
<Rule />
|
||||||
|
<Stack gap={3}>
|
||||||
|
<Heading level={3}>Engineering</Heading>
|
||||||
|
<Text size="small" muted>Zero trust, key management, secure APIs.</Text>
|
||||||
|
</Stack>
|
||||||
|
<Rule />
|
||||||
|
<Stack gap={3}>
|
||||||
|
<Heading level={3}>Verification</Heading>
|
||||||
|
<Text size="small" muted>Independent review, hardening, testing.</Text>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Surface, Section, Container, Stack, Heading, Text } from 'ciphercell-homepage';
|
||||||
|
|
||||||
|
export const Default = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section>
|
||||||
|
<Container>
|
||||||
|
<Stack gap={5}>
|
||||||
|
<Heading>Who we work with</Heading>
|
||||||
|
<Text measure muted>Operators, integrators and the people who run critical networks.</Text>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Tight = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Heading level={3}>In numbers.</Heading>
|
||||||
|
<Text measure muted>A subordinate band carries less air than a full section.</Text>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Intro = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="intro">
|
||||||
|
<Container>
|
||||||
|
<Stack gap={5}>
|
||||||
|
<Heading>Insights</Heading>
|
||||||
|
<Text measure muted>
|
||||||
|
Notes on software security in private 5G, critical networks and the systems that run them.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Night = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section tone="night">
|
||||||
|
<Container>
|
||||||
|
<Stack gap={5}>
|
||||||
|
<Heading>Where we come from.</Heading>
|
||||||
|
<Text measure muted>
|
||||||
|
CipherCell grew out of Open RAN security research at the University of Passau.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Surface, Section, Container, Stack, Heading, Text, Button } from 'ciphercell-homepage';
|
||||||
|
|
||||||
|
export const Default = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Stack gap={5}>
|
||||||
|
<Heading>Four stages. One method.</Heading>
|
||||||
|
<Text measure>
|
||||||
|
Threat analysis, engineering, verification and monitoring, applied to your software as
|
||||||
|
one continuous method rather than four separate projects.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Tight = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="small" muted>Threat analysis</Text>
|
||||||
|
<Text size="small">Threat model</Text>
|
||||||
|
<Text size="small">Attack paths</Text>
|
||||||
|
<Text size="small">CI/CD integration</Text>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AlignStart = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Stack gap={5} align="start">
|
||||||
|
<Text measure muted>A button should keep its natural width.</Text>
|
||||||
|
<Button href="/contact">Talk to us</Button>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Surface, Section, Container, Stack, Heading, Text, Button } from 'ciphercell-homepage';
|
||||||
|
|
||||||
|
export const Paper = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Stack gap={5}>
|
||||||
|
<Heading>Security is built in,<br />not bolted on.</Heading>
|
||||||
|
<Text measure>
|
||||||
|
Infrastructure is software now. Every interface between components is an attack surface.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Night = () => (
|
||||||
|
<Surface tone="night">
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Stack gap={5} align="start">
|
||||||
|
<Heading>Four stages.<br />One method.</Heading>
|
||||||
|
<Text measure muted>
|
||||||
|
Threat analysis, engineering, verification and monitoring, applied as one method.
|
||||||
|
</Text>
|
||||||
|
<Button href="/cipherverifier">Explore CipherVerifier</Button>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Surface, Section, Container, Stack, Text } from 'ciphercell-homepage';
|
||||||
|
|
||||||
|
export const Measured = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Text measure>
|
||||||
|
Infrastructure is software now. Private 5G networks, industrial control systems and cloud
|
||||||
|
platforms are assembled from the code and components of many vendors. Every interface
|
||||||
|
between them is an attack surface, and off-the-shelf tools were not built for that.
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Muted = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Text measure muted>
|
||||||
|
CipherCell is a security engineering company for software. From the first threat model to
|
||||||
|
the running system.
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Small = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Stack gap={3}>
|
||||||
|
<Text size="small">Threat model, attack paths, CI/CD integration.</Text>
|
||||||
|
<Text size="small" muted>Passau, Innstr. 43</Text>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Surface, Section, Container, Stack, TextLink, Text } from 'ciphercell-homepage';
|
||||||
|
|
||||||
|
export const Standalone = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Stack gap={5} align="start">
|
||||||
|
<TextLink href="/method">Explore the method</TextLink>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const InSentence = () => (
|
||||||
|
<Surface>
|
||||||
|
<Section spacing="tight">
|
||||||
|
<Container>
|
||||||
|
<Text measure>
|
||||||
|
Every finding is verified twice. The pipeline is described in{' '}
|
||||||
|
<TextLink href="/cipherverifier">the CipherVerifier notes</TextLink>.
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
+7
-1
@@ -1,5 +1,11 @@
|
|||||||
node_modules/
|
|
||||||
dist/
|
dist/
|
||||||
|
node_modules/
|
||||||
.astro/
|
.astro/
|
||||||
# The product source is mounted here for reference only; it carries its own repository and .env files.
|
# The product source is mounted here for reference only; it carries its own repository and .env files.
|
||||||
product_repo/
|
product_repo/
|
||||||
|
# design-sync: staged scripts, build output and machine state
|
||||||
|
.ds-sync/
|
||||||
|
ds-bundle/
|
||||||
|
.design-sync/.cache/
|
||||||
|
.design-sync/learnings/
|
||||||
|
.design-sync/node_modules
|
||||||
@@ -23,14 +23,15 @@ npm run check # Astro + TypeScript check
|
|||||||
| `src/data/team.ts` | Team members, roles, portraits, links. |
|
| `src/data/team.ts` | Team members, roles, portraits, links. |
|
||||||
| `src/content/insights/*.md` | Insights posts (content collection). Front matter: title, date, lang, category, readingTime, author, cover. |
|
| `src/content/insights/*.md` | Insights posts (content collection). Front matter: title, date, lang, category, readingTime, author, cover. |
|
||||||
| `src/components/home/*` | One file per home section, in page order: Hero, Manifesto, Method (with `MethodDiagram`), Stages (with `GridObject`), Sectors, Proof, Quote, Insights, Contact. |
|
| `src/components/home/*` | One file per home section, in page order: Hero, Manifesto, Method (with `MethodDiagram`), Stages (with `GridObject`), Sectors, Proof, Quote, Insights, Contact. |
|
||||||
| `src/components/home/GridObject.astro` | The four wireframe objects on the stage cards (sphere, cube, plane, wave). Geometry is computed at build time; single stroke in `--ink`. |
|
| `src/components/home/GridObject.astro` | The wireframe objects on the stage and product cards (sphere, cube, plane, wave, steps, nested, stack). Geometry is computed at build time; single stroke in `--ink`. Each drawing is laid out by hand, so the `viewBox` is derived from the points as they are emitted, not assumed: a square centred on the drawing, padded for the stroke. A new kind can therefore never be clipped, and the card gives the drawing the flexible row so it sits centred between title and body. Each drawing is emitted in two groups, the lines that carry its idea and the grid around them, which is what the hover and the reveal act on. |
|
||||||
| `src/components/home/MethodDiagram.astro` | The four-stage loop, wide and tall compositions. Labels come from `ui.method.stages`. |
|
| `src/components/home/MethodDiagram.astro` | The four-stage loop, wide and tall compositions. Labels come from `ui.method.stages`. |
|
||||||
| `src/components/verifier/*` | The CipherVerifier product page, in page order: Hero (line burst), Intro (image band), HowItWorks (tabs, one schematic per group), Cards, Facts, Outro. |
|
| `src/components/verifier/*` | The CipherVerifier product page, in page order: Hero (line burst), Intro (image band), HowItWorks (five group cards, one schematic each; on wide screens the heading is sticky and the cards stack under it as you scroll), Cards, Facts, Outro. |
|
||||||
| `src/components/verifier/diagrams.ts` | The five schematics as data: a hand-placed wide layout and an auto-generated tall spine per group. `Schematic.astro` renders either. Labels come from `ui.verifier.how.groups`; the tuple shape is in `types.ts`. |
|
| `src/components/verifier/diagrams.ts` | The five schematics as data: a hand-placed wide layout and an auto-generated tall spine per group. `Schematic.astro` renders either. Labels come from `ui.verifier.how.groups`; the tuple shape is in `types.ts`. Every element carries the `group` of the node it belongs to, which drives the hover focus and the draw-in order. |
|
||||||
| `scripts/*.mjs` | Review tooling: `shot.mjs` (full page), `scroll-shots.mjs` (viewport by viewport), `tabs-test.mjs` (keyboard test for the sectors tabs), `contact-sheet.mjs`. Need `npx astro preview --port 4322`. |
|
| `scripts/*.mjs` | Review tooling: `shot.mjs` (full page), `scroll-shots.mjs` (viewport by viewport), `tabs-test.mjs` (keyboard test for the sectors tabs), `contact-sheet.mjs`. Need `npx astro preview --port 4322`. |
|
||||||
| `src/pages/_*.astro` | Shared page bodies. `index.astro` and `de/index.astro` render `_Home.astro` with a `lang` prop, and so on. |
|
| `src/pages/_*.astro` | Shared page bodies. `index.astro` and `de/index.astro` render `_Home.astro` with a `lang` prop, and so on. |
|
||||||
| `src/assets/images/` | Source images. Astro emits AVIF with a WebP fallback and explicit dimensions. |
|
| `src/assets/images/` | Source images. Astro emits AVIF with a WebP fallback and explicit dimensions. |
|
||||||
| `public/fonts/` | Self-hosted Inter Tight variable (latin, latin-ext). |
|
| `public/fonts/` | Self-hosted Inter Tight variable (latin, latin-ext). |
|
||||||
|
| `public/video/` | The home hero's background loop, MP4 and WebM. Shipped as-is; Astro does not process video. |
|
||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
|
|
||||||
@@ -51,6 +52,39 @@ Create `src/content/insights/<slug>.md` with the front matter used in `xg-aloe-u
|
|||||||
Put the cover image in `src/assets/images/` and reference it with a relative path.
|
Put the cover image in `src/assets/images/` and reference it with a relative path.
|
||||||
The three newest posts appear on the home page automatically.
|
The three newest posts appear on the home page automatically.
|
||||||
|
|
||||||
|
## The card drawings
|
||||||
|
|
||||||
|
The objects on the stage cards ("What we do.") and the product cards do not animate on arrival;
|
||||||
|
they are simply drawn. Hovering a card sets `--object-grid`, which the drawing answers by letting
|
||||||
|
its grid recede to 26% while the lines that carry the idea stay: the cube's own edges, the tile
|
||||||
|
lifted out of the plane, the wave's profiles, the inner cube inside its shell, the column to fix
|
||||||
|
first. The card also sets `--object-scale`. Both are custom properties the card owns and the
|
||||||
|
drawing responds to, so the interaction lives with the card and the response with the drawing.
|
||||||
|
It is behind `@media (hover: hover)` so touch never sticks in it, and it is the same language as
|
||||||
|
the hover focus on the product page schematics.
|
||||||
|
|
||||||
|
## The hero video
|
||||||
|
|
||||||
|
The home hero plays `public/video/hero-plant.mp4` / `.webm` behind the headline, over the
|
||||||
|
`plant-at-dusk.jpg` still. The still is the video's own first frame, so it carries the hero until
|
||||||
|
the video is ready and stays put for anyone who never gets one.
|
||||||
|
|
||||||
|
The video is only fetched when it will be seen: not under `prefers-reduced-motion`, not on
|
||||||
|
`saveData`, and not below 48rem, where a 21:9 frame would be cropped to a sliver for the most
|
||||||
|
expensive bytes. The sources are attached by script rather than in the markup, so nothing is
|
||||||
|
requested otherwise. A blocked autoplay also just leaves the still in place.
|
||||||
|
|
||||||
|
The source clip was generated with Magnific (Kling 2.5, 10 s, from the hero still as its start
|
||||||
|
frame, https://www.magnific.com/app/creation/5jPaI3qKxe). `scripts/encode-hero-video.sh` turns a
|
||||||
|
clip into the looping file: it plays forward, then backward, so the loop point cannot be seen, and
|
||||||
|
encodes 1920-wide H.264 and VP9. A 20-second loop lands at 862 KB and 472 KB. To replace it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
FFMPEG=/path/to/ffmpeg ./scripts/encode-hero-video.sh source.mp4 public/video/hero-plant 1920
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the first frame of any replacement equal to `plant-at-dusk.jpg`, or replace both together.
|
||||||
|
|
||||||
## Imagery
|
## Imagery
|
||||||
|
|
||||||
The mast at dusk (`mast-at-dusk.jpg`) is the one documentary photograph from the old site; it
|
The mast at dusk (`mast-at-dusk.jpg`) is the one documentary photograph from the old site; it
|
||||||
@@ -90,12 +124,35 @@ by git). Everything on the page was read from that repository on 18 September 20
|
|||||||
are the `cipherverifier` Django app; the installer is `cvm`.
|
are the `cipherverifier` Django app; the installer is `cvm`.
|
||||||
|
|
||||||
When the product changes, update `ui.verifier` (copy and diagram labels) and, if a stage or tool is
|
When the product changes, update `ui.verifier` (copy and diagram labels) and, if a stage or tool is
|
||||||
added, the layouts in `diagrams.ts`. Run `node scripts/tabs-shots.mjs <url> <prefix> '[data-how]'`
|
added, the layouts in `diagrams.ts`. All five schematics are on the page; `scroll-shots.mjs` renders
|
||||||
to render every schematic for review.
|
them for review.
|
||||||
|
|
||||||
## Interaction
|
## Interaction and motion
|
||||||
|
|
||||||
Two islands of script: the mobile navigation toggle in `Header.astro` and the tab lists in
|
Three islands of script: the mobile navigation toggle in `Header.astro`, the sectors tabs in
|
||||||
`Sectors.astro` and `HowItWorks.astro`, which share `src/scripts/tabs.ts` (ARIA tablist, arrow
|
`Sectors.astro` (`src/scripts/tabs.ts`: ARIA tablist, arrow keys, Home and End), and the reveal
|
||||||
keys, Home and End). Without JavaScript every sector panel
|
observer used by the product page's drawings.
|
||||||
|
|
||||||
|
Motion on the product page, all of it on wide screens: the hero's seventeen lines draw in on load
|
||||||
|
and meet in one point (off under `prefers-reduced-motion`); hovering a node in a schematic dims
|
||||||
|
everything that does not belong to it. The drawings themselves do not animate.
|
||||||
|
|
||||||
|
"How CipherVerifier works" follows the pattern of arcos.eu/technology. On wide screens the
|
||||||
|
title and a bordered panel hold still while the page scrolls; the step list under the title
|
||||||
|
advances, one step open at a time, and the panel crossfades to that step's drawing. The section is
|
||||||
|
as tall as one pitch per step plus one panel height, so the panel stays pinned until the last step
|
||||||
|
has been read. Which step is open is decided by invisible triggers tiling that height: whichever
|
||||||
|
crosses the middle of the viewport wins, and the triggers start half a viewport down so the first
|
||||||
|
step turns over exactly when the panel pins. A step's title is a button that scrolls to it.
|
||||||
|
|
||||||
|
The panel is tied to the text, not to the pinned frame: it is positioned against
|
||||||
|
`.how__text-inner`, so it starts at the title and its width is stated as 200% of the text column
|
||||||
|
(4 of 12 against 8 of 12). That block is held at one height, measured once by script as the
|
||||||
|
collapsed list plus the tallest step's text, and centred in the pinned frame. So the title, the
|
||||||
|
list and the panel all sit in the middle of the dark area and none of them move as the steps
|
||||||
|
change, while each step's text still takes only the room it needs. Without script the first step stays open, the panel takes the text's own
|
||||||
|
height, and the section reads as a plain sequence. Phones get that plain sequence in any case:
|
||||||
|
every step open, the upright drawing under its text. This goes
|
||||||
|
beyond the home page's single load moment on purpose, at the client's request; the home page is
|
||||||
|
unchanged. Without JavaScript every sector panel
|
||||||
and its image is shown in full; the `html.js` class set in `Base.astro` switches to one at a time.
|
and its image is shown in full; the `html.js` class set in `Base.astro` switches to one at a time.
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<!-- Generated from CLAUDE.md by .design-sync/make-ds-styles.mjs. Edit CLAUDE.md, not this file. -->
|
||||||
|
|
||||||
|
# CipherCell
|
||||||
|
|
||||||
|
**Company.** CipherCell, Passau (Innstr. 43). A security engineering company that grew out of Open RAN
|
||||||
|
security research and works with the University of Passau, the xG-ALOE alliance and the xG-Incubator,
|
||||||
|
with funding from the Federal Ministry of Research, Technology and Space. Eight people: two founders
|
||||||
|
(Dr. Felix Klement, CEO; Dr. Florian Frank, CTO), engineers, business development, working students.
|
||||||
|
|
||||||
|
**What they sell.** Security engineering for software, mainly: the software behind complex
|
||||||
|
infrastructure. Four stages: threat analysis and
|
||||||
|
risk assessment (automated, in CI/CD), security engineering (zero trust, key management, secure APIs),
|
||||||
|
security verification (independent review, hardening, testing) and monitoring and lifecycle.
|
||||||
|
Sectors: Open RAN and private 5G, critical network infrastructure, cloud-native systems.
|
||||||
|
|
||||||
|
**Audience.** Operators of private 5G and Open RAN networks, integrators, industrial and clinical
|
||||||
|
operators of critical networks, public research partners. Technical buyers who distrust marketing.
|
||||||
|
|
||||||
|
**Purpose of the site.** Establish CipherCell as an institution rather than a startup: state what
|
||||||
|
they do in plain sentences, show the method, publish their thinking (Insights), and make contact easy.
|
||||||
|
Bilingual EN/DE. Never invent proof points: every claim on the site traces to the current site,
|
||||||
|
the founders' published writing or the funding badge.
|
||||||
|
|
||||||
|
**Implementation.** Astro, plain CSS custom properties, self-hosted Inter Tight. Copy lives in
|
||||||
|
`src/i18n/ui.ts`, tokens in `src/styles/tokens.css`. See `README.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Design Brief: European Deep-Tech Minimalism
|
||||||
|
|
||||||
|
This file is the binding design foundation for this project. If a decision isn't covered here, decide in the spirit of this brief, not by the usual SaaS default.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Attitude
|
||||||
|
|
||||||
|
The site should look like an institution, not a startup. Composed, serious, understated. The impression comes from restraint: large surfaces, few elements, big typography, documentary photography.
|
||||||
|
|
||||||
|
Three rules that override everything else:
|
||||||
|
|
||||||
|
1. **Typography is the primary design tool.** Not color, not icons, not illustration.
|
||||||
|
2. **White space is content.** If a section feels too empty, it's probably right.
|
||||||
|
3. **No element without a job.** Borders, rules, labels and numbering may only appear when they carry information.
|
||||||
|
|
||||||
|
Reference points for the look: Arcos, Helsing, Anduril, Palantir, and the Swiss poster tradition they draw on. Not: Stripe, Linear clones, Vercel templates, standard YC landing pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Color
|
||||||
|
|
||||||
|
The foundation is a **warm paper tone**, never pure white. Contrast comes from exactly three states: paper, night, image.
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--paper: #E9E5DF; /* base surface for the entire site */
|
||||||
|
--paper-deep: #DFDAD1; /* offset surfaces, used sparingly */
|
||||||
|
--ink: #1A1A18; /* body copy and headlines, warm-broken black */
|
||||||
|
--ink-muted: #57564F; /* secondary text, metadata */
|
||||||
|
--night: #0E100F; /* dark sections, image overlays */
|
||||||
|
--line: rgb(26 26 24 / 0.14); /* hairlines */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Why warm black: on warm paper, pure black reads cold and foreign. `--ink` shares the warmth of the ground it sits on.
|
||||||
|
|
||||||
|
**Accent color: none by default.** Links and buttons work through underline, weight and surface, not color. If a signal tone is unavoidable (a live status dot, an alarm state in product UI), define exactly one and use it at most twice per page. Always build and look at the colorless version first.
|
||||||
|
|
||||||
|
Dark sections are allowed but rare: the hero and at most one other. The move from paper to night and back is a dramatic device and loses its effect through repetition.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Typography
|
||||||
|
|
||||||
|
One typeface family for the whole site: a **neo-grotesque** with tight counters and little personality in the details.
|
||||||
|
|
||||||
|
- Licensed (preferred if there's budget): Suisse Int'l, ABC Diatype, Söhne, Helvetica Now Display
|
||||||
|
- Free: **Inter Tight** (default recommendation), Instrument Sans, Archivo, Geist
|
||||||
|
|
||||||
|
No serif. No second family for display. No monospace for labels.
|
||||||
|
|
||||||
|
Fonts are self-hosted `.woff2` only, with `font-display: swap`, variable font where available.
|
||||||
|
|
||||||
|
### Scale
|
||||||
|
|
||||||
|
Fluid scale using `clamp()`. The display size may be genuinely large — it's the one place where the page raises its voice.
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--step-display: clamp(2.75rem, 1.2rem + 6.2vw, 7rem);
|
||||||
|
--step-h2: clamp(1.9rem, 1.1rem + 2.6vw, 3.25rem);
|
||||||
|
--step-h3: clamp(1.25rem, 1.05rem + 0.7vw, 1.6rem);
|
||||||
|
--step-body: clamp(1rem, 0.96rem + 0.2vw, 1.125rem);
|
||||||
|
--step-small: 0.875rem;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
| Level | Weight | Tracking | Line height |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Display | 400–500 | `-0.03em` | 1.02 |
|
||||||
|
| H2 | 400–500 | `-0.02em` | 1.08 |
|
||||||
|
| H3 | 500 | `-0.01em` | 1.2 |
|
||||||
|
| Body | 400 | 0 | 1.55 |
|
||||||
|
| Small / meta | 400–500 | 0 | 1.4 |
|
||||||
|
|
||||||
|
- The larger the size, the tighter the tracking. Never the reverse.
|
||||||
|
- Body measure of 68 characters maximum (`max-width: 34em`).
|
||||||
|
- Line breaks in headlines are set by hand (`<br>` or `text-wrap: balance`), not left to the browser. A headline is a composed object.
|
||||||
|
- Everything flush left. Centered text only where there is a reason you can name.
|
||||||
|
- **No all-caps label above headings.** If a section needs an eyebrow, set it in sentence case, in `--ink-muted`, at the same size as small text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
max-width: 1440px, centered
|
||||||
|
Page margins: clamp(1.25rem, 5vw, 5rem)
|
||||||
|
Grid: 12 columns, gap clamp(1rem, 2vw, 2rem)
|
||||||
|
Section spacing: clamp(6rem, 12vw, 11rem) top and bottom
|
||||||
|
```
|
||||||
|
|
||||||
|
Text blocks sit **asymmetrically**: headline across 7–8 columns, supporting body copy across 4–5, often offset against each other. Full width is reserved for images and the display headline.
|
||||||
|
|
||||||
|
Radii: `0`, at most `2px`. No rounded cards.
|
||||||
|
Shadows: none. Separation comes from hairlines (`1px solid var(--line)`) or from a change of surface.
|
||||||
|
|
||||||
|
Home page wireframe:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ Wordmark Navigation Contact │ ← slim, hairline below
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ [Full-bleed darkened image / video] │
|
||||||
|
│ │
|
||||||
|
│ Large display headline, │ ← white on image, lower left
|
||||||
|
│ two lines. │
|
||||||
|
│ │
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Manifesto headline. Two paragraphs │ ← paper, generous air
|
||||||
|
│ Second line. of body copy │
|
||||||
|
│ [Link] │
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ [Image] │ System / product headline │ ← 50/50, image bleeds off
|
||||||
|
│ │ Explanation, link │
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ Sectors │
|
||||||
|
│ ┌──────────┬──────────┬──────────┐ │ ← tabs or 3 columns,
|
||||||
|
│ │ Image │ Image │ Image │ │ divided by hairlines
|
||||||
|
│ │ Title │ Title │ Title │ │
|
||||||
|
│ │ Sentence │ Sentence │ Sentence │ │
|
||||||
|
│ └──────────┴──────────┴──────────┘ │
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ Proof row: three claims, type and rules only │
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ Quote, set large [Portrait] │
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ Insights: 3 posts, image + category + title │
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ Footer: mission sentence, 4 link columns │
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Imagery
|
||||||
|
|
||||||
|
- Documentary, not staged. Aerial views, industrial sites, street scenes, people at work, reflections in glass.
|
||||||
|
- Desaturated and darkened. Overlay: `linear-gradient(to top, rgb(14 16 15 / 0.75), rgb(14 16 15 / 0.2))`.
|
||||||
|
- No stock-photo look, no smiling teams against white, no 3D renders, no generic icon sets.
|
||||||
|
- Products appear as **technical line drawings**: single-color stroke in `--ink`, even stroke width, like a spec sheet. Inline SVG.
|
||||||
|
- AVIF with WebP fallback, `<picture>`, `loading="lazy"` except in the hero, explicit `width`/`height` to prevent layout shift.
|
||||||
|
- Every image gets descriptive alt text in the voice of the site ("An aerial view of pipework at an industrial plant"), not "Image 1".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Motion
|
||||||
|
|
||||||
|
One orchestrated moment on home page load: the hero image settles in from a slight scale, the headline fades in line by line. After that, stillness.
|
||||||
|
|
||||||
|
Banned, because generic:
|
||||||
|
- Fade-and-slide-up on every section as it scrolls into view
|
||||||
|
- Hover lift with shadow on every card
|
||||||
|
- Parallax
|
||||||
|
- Counting-up numbers
|
||||||
|
|
||||||
|
Allowed: state changes that answer a user action — navigation opening, a tab switching, an accordion expanding. Duration 180–320 ms, `cubic-bezier(0.2, 0, 0, 1)`.
|
||||||
|
|
||||||
|
`@media (prefers-reduced-motion: reduce)` turns all of it off. No exceptions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Language
|
||||||
|
|
||||||
|
Copy carries the style as much as the layout does. Rules:
|
||||||
|
|
||||||
|
- Short declarative sentences, with a full stop. "Safety is made, not given."
|
||||||
|
- Contrast pairs as a structural device: "Verifiable, not asserted."
|
||||||
|
- Sentence case, no exclamation marks, no rhetorical questions.
|
||||||
|
- Concrete nouns over categories: "One signal, and a person answers." not "Innovative solutions for your security."
|
||||||
|
- Banned: seamless, revolutionary, holistic, next-level, end-to-end, "At X, we believe that…", "In today's fast-moving world".
|
||||||
|
- Buttons name the action: "Explore technology", not "Learn more". No "→" inside button text.
|
||||||
|
- Headlines may state a claim, but never advertise.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Anti-patterns
|
||||||
|
|
||||||
|
These destroy the style immediately. Don't build them, not even "just in one place":
|
||||||
|
|
||||||
|
- Gradients as decoration, glassmorphism, blurred blobs
|
||||||
|
- Identical rounded cards with soft shadows for every kind of content
|
||||||
|
- Colored or filled icon sets, emoji
|
||||||
|
- An all-caps eyebrow above every heading
|
||||||
|
- Meta lines joined with middle dots ("Analysis · 5 min · 2026")
|
||||||
|
- Monospace for small text and labels
|
||||||
|
- A single word in the headline picked out in color
|
||||||
|
- 01 / 02 / 03 numbering where the content isn't a sequence
|
||||||
|
- A "Trusted by" logo bar with greyed-out logos
|
||||||
|
- Cookie-banner styling that breaks with everything else
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Technical
|
||||||
|
|
||||||
|
Proposal, unless something else is already fixed:
|
||||||
|
|
||||||
|
- **Astro** as the framework, islands only where interactivity is genuinely needed (navigation, tabs). Static HTML as the default.
|
||||||
|
- **Tailwind v4** with the tokens above in `@theme`, or plain CSS with custom properties. No UI library, no component kit.
|
||||||
|
- Content collections for news and insights, Markdown as the source.
|
||||||
|
- Bilingual EN/DE via `/` and `/de` routes, language switch in the footer.
|
||||||
|
- Self-hosted fonts, no third-party font loading.
|
||||||
|
- Target: Lighthouse ≥ 95 across all four categories, LCP under 2.0 s, CLS 0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. How to work
|
||||||
|
|
||||||
|
Keep to this order:
|
||||||
|
|
||||||
|
1. **Tokens first.** Create `src/styles/tokens.css` with color, type scale and spacing scale, and commit it before any component exists. Hard-coded hex values or `px` spacing inside components are a defect and get reverted.
|
||||||
|
2. **Primitives next.** Container, grid, section, prose. All spacing comes from the scale.
|
||||||
|
3. **One section at a time**, each with real content. No lorem ipsum — write real copy following section 7, even if it will be replaced later.
|
||||||
|
4. **Look at it and criticize it.** After each section, take screenshots at 1440 px and 390 px. Check against the anti-patterns in section 8 and state what you're changing.
|
||||||
|
5. **Remove one thing.** Before a section counts as done: which element carries the least? Cut it.
|
||||||
|
|
||||||
|
### Definition of done, per section
|
||||||
|
|
||||||
|
- [ ] Tokens only, no magic numbers
|
||||||
|
- [ ] Checked at 390 px, 768 px, 1440 px and 1920 px
|
||||||
|
- [ ] Visible focus ring on every interactive element, full keyboard operation
|
||||||
|
- [ ] Contrast at least 4.5:1 for body text, 3:1 for large text
|
||||||
|
- [ ] Semantic HTML, one `<h1>` per page, no skipped heading levels
|
||||||
|
- [ ] `prefers-reduced-motion` respected
|
||||||
|
- [ ] Images with alt text, dimensions and a modern format
|
||||||
|
- [ ] Checked against section 8
|
||||||
|
|
||||||
|
### When to ask
|
||||||
|
|
||||||
|
Ask rather than guess when: the company and its industry aren't established, no real photography exists, or a section needs content only the client has (certifications, quotes, figures). Never invent proof points, certifications or testimonials.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Save this file as `CLAUDE.md` in the project root. Claude Code picks it up automatically as project context. Add a short section at the top covering the specific company, its audience and the purpose of the site — without that, the design stays generic.
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"types": ".design-sync/ds-types/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,21 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
/* Jumps to card 3 via its dot, then back to card 1 via its dot, and reports where the page rests. */
|
||||||
|
const [,, url, width = '1440', height = '900'] = process.argv;
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle' });
|
||||||
|
const snaps = await page.evaluate(() => Array.from(document.querySelectorAll('[data-deck-snap]')).map((a) => Math.round(a.getBoundingClientRect().top + window.scrollY - parseFloat(getComputedStyle(a).scrollMarginTop))));
|
||||||
|
const dots = page.locator('[data-deck-dots] a');
|
||||||
|
await page.evaluate((y) => window.scrollTo(0, y), snaps[2]);
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
const before = await page.evaluate(() => Math.round(window.scrollY));
|
||||||
|
await dots.nth(0).click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
const back = await page.evaluate(() => Math.round(window.scrollY));
|
||||||
|
await dots.nth(3).click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
const fwd = await page.evaluate(() => Math.round(window.scrollY));
|
||||||
|
const active = await page.evaluate(() => Array.from(document.querySelectorAll('[data-deck-dots] a')).map((a) => a.getAttribute('aria-current') === 'true'));
|
||||||
|
const hint = await page.evaluate(() => { const h = document.querySelector('[data-deck-hint]'); return { href: h?.getAttribute('href'), last: h?.hasAttribute('data-last') }; });
|
||||||
|
console.log({ snaps, before, backTo1: back, expected1: snaps[0], fwdTo4: fwd, expected4: snaps[3], active, hint });
|
||||||
|
await browser.close();
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Turns a generated clip into a seamless looping hero background:
|
||||||
|
# the clip plays forward, then backward, so the loop point cannot be seen.
|
||||||
|
# Usage: encode-hero-video.sh <source.mp4> <out-basename> [width]
|
||||||
|
set -euo pipefail
|
||||||
|
FFMPEG="${FFMPEG:-ffmpeg}"
|
||||||
|
SRC="$1"; OUT="$2"; W="${3:-1920}"
|
||||||
|
FILTER="[0:v]scale=${W}:-2:flags=lanczos,fps=25,setpts=PTS-STARTPTS,split[a][b];[b]reverse,trim=start_frame=1,setpts=PTS-STARTPTS[r];[a][r]concat=n=2:v=1[v]"
|
||||||
|
"$FFMPEG" -y -i "$SRC" -filter_complex "$FILTER" -map "[v]" -an \
|
||||||
|
-c:v libx264 -profile:v high -crf 30 -preset slow -pix_fmt yuv420p -movflags +faststart "${OUT}.mp4"
|
||||||
|
"$FFMPEG" -y -i "$SRC" -filter_complex "$FILTER" -map "[v]" -an \
|
||||||
|
-c:v libvpx-vp9 -crf 40 -b:v 0 -row-mt 1 -deadline good -cpu-used 2 "${OUT}.webm"
|
||||||
|
ls -lh "${OUT}.mp4" "${OUT}.webm"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
/* Checks the hero video: does it load and play, is it gated off on phones and under reduced
|
||||||
|
motion, and does the hero fill the viewport. */
|
||||||
|
const [,, url, prefix] = process.argv;
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
|
||||||
|
async function probe(label, opts, shots = []) {
|
||||||
|
const ctx = await browser.newContext(opts);
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
const requested = [];
|
||||||
|
page.on('request', (r) => { if (/\.(mp4|webm)$/.test(r.url())) requested.push(r.url().split('/').pop()); });
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle' });
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
const info = await page.evaluate(() => {
|
||||||
|
const v = document.querySelector('video');
|
||||||
|
const hero = document.querySelector('.hero');
|
||||||
|
return {
|
||||||
|
hasVideo: !!v,
|
||||||
|
ready: v?.hasAttribute('data-ready') ?? null,
|
||||||
|
paused: v?.paused ?? null,
|
||||||
|
currentTime: v ? Math.round(v.currentTime * 10) / 10 : null,
|
||||||
|
videoSize: v ? `${v.videoWidth}x${v.videoHeight}` : null,
|
||||||
|
heroHeight: Math.round(hero.getBoundingClientRect().height),
|
||||||
|
viewport: window.innerHeight,
|
||||||
|
headerH: Math.round(document.querySelector('header').getBoundingClientRect().height),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
for (const t of shots) {
|
||||||
|
await page.waitForTimeout(t);
|
||||||
|
await page.screenshot({ path: `${prefix}-${label}-${t}.png` });
|
||||||
|
}
|
||||||
|
console.log(label, JSON.stringify({ ...info, requested }));
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
await probe('desktop', { viewport: { width: 1440, height: 900 } }, [0, 2500]);
|
||||||
|
await probe('mobile', { viewport: { width: 390, height: 844 } });
|
||||||
|
await probe('reduced', { viewport: { width: 1440, height: 900 }, reducedMotion: 'reduce' });
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
/* Reports where the chevron hint sits at the first snap and whether anything covers it. */
|
||||||
|
const [,, url, width = '1440', height = '900'] = process.argv;
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle' });
|
||||||
|
const snaps = await page.evaluate(() => Array.from(document.querySelectorAll('[data-deck-snap]')).map((a) => Math.round(a.getBoundingClientRect().top + window.scrollY - parseFloat(getComputedStyle(a).scrollMarginTop))));
|
||||||
|
await page.evaluate((y) => window.scrollTo(0, y), snaps[0]);
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
const info = await page.evaluate(() => {
|
||||||
|
const h = document.querySelector('[data-deck-hint]');
|
||||||
|
const r = h.getBoundingClientRect();
|
||||||
|
const cs = getComputedStyle(h);
|
||||||
|
const svg = h.querySelector('svg').getBoundingClientRect();
|
||||||
|
const atPoint = document.elementFromPoint(window.innerWidth / 2, window.innerHeight - 10);
|
||||||
|
const next = document.querySelectorAll('[data-deck-card]')[1].getBoundingClientRect();
|
||||||
|
return { hint: { top: r.top, bottom: r.bottom, h: r.height }, svg: { top: svg.top, bottom: svg.bottom }, position: cs.position, display: cs.display, bottom: cs.bottom, zIndex: cs.zIndex, opacity: cs.opacity, atPoint: atPoint && (atPoint.tagName + '.' + atPoint.className), nextCardTop: next.top, innerHeight: window.innerHeight };
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(info, null, 1));
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
/* Hovers one node group of a schematic and captures the drawing. */
|
||||||
|
const [,, url, out, selector, group] = process.argv;
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle' });
|
||||||
|
const svg = page.locator(selector).first();
|
||||||
|
await svg.scrollIntoViewIfNeeded();
|
||||||
|
await page.waitForTimeout(3500);
|
||||||
|
await svg.locator(`[data-group="${group}"] .s-ring`).first().hover({ force: true, position: { x: 6, y: 36 } });
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await svg.screenshot({ path: out });
|
||||||
|
await browser.close();
|
||||||
|
console.log('hovered', group);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
/* Phone: the sweep should still run, the hover state should not exist. */
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.goto(process.argv[2], { waitUntil: 'networkidle' });
|
||||||
|
await page.locator('.stages__list').scrollIntoViewIfNeeded();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
console.log('mobile shown:', JSON.stringify(await page.evaluate(() =>
|
||||||
|
Array.from(document.querySelectorAll('[data-reveal]')).map((o) => o.hasAttribute('data-shown')))));
|
||||||
|
console.log('grid opacity (no hover):', await page.evaluate(() =>
|
||||||
|
getComputedStyle(document.querySelector('.grid-object__base')).opacity));
|
||||||
|
await page.screenshot({ path: process.argv[3] });
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
/* Scrolls a target into view and captures it at several moments, to see a draw-in happen. */
|
||||||
|
const [,, url, prefix, selector, width = '1440', times = '150,700,1600,3200'] = process.argv;
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ viewport: { width: Number(width), height: 900 } });
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle' });
|
||||||
|
const el = page.locator(selector).first();
|
||||||
|
await el.scrollIntoViewIfNeeded();
|
||||||
|
const t0 = Date.now();
|
||||||
|
for (const t of times.split(',').map(Number)) {
|
||||||
|
const wait = t - (Date.now() - t0);
|
||||||
|
if (wait > 0) await page.waitForTimeout(wait);
|
||||||
|
await el.screenshot({ path: `${prefix}-${t}.png` });
|
||||||
|
}
|
||||||
|
await browser.close();
|
||||||
|
console.log('captured', selector);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
/* For every grid object: does the drawn geometry fit inside its viewBox, and is the drawing
|
||||||
|
centred in the space its card gives it. */
|
||||||
|
const [,, url, selector, width = '1440'] = process.argv;
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ viewport: { width: Number(width), height: 1000 } });
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle' });
|
||||||
|
const rows = await page.evaluate((sel) => {
|
||||||
|
return Array.from(document.querySelectorAll(sel)).map((svg, i) => {
|
||||||
|
const vb = svg.viewBox.baseVal;
|
||||||
|
const b = svg.querySelector('g').getBBox();
|
||||||
|
const overflow = {
|
||||||
|
left: +(vb.x - b.x).toFixed(1),
|
||||||
|
right: +((b.x + b.width) - (vb.x + vb.width)).toFixed(1),
|
||||||
|
top: +(vb.y - b.y).toFixed(1),
|
||||||
|
bottom: +((b.y + b.height) - (vb.y + vb.height)).toFixed(1),
|
||||||
|
};
|
||||||
|
const clipped = Object.values(overflow).some((v) => v > 0.5);
|
||||||
|
/* Vertical centring: the gap above the drawing vs the gap below it, inside the card. */
|
||||||
|
const card = svg.closest('li');
|
||||||
|
const title = card.querySelector('h2, h3');
|
||||||
|
const body = card.querySelector('p');
|
||||||
|
const r = svg.getBoundingClientRect();
|
||||||
|
const above = +(r.top - title.getBoundingClientRect().bottom).toFixed(1);
|
||||||
|
const below = +(body.getBoundingClientRect().top - r.bottom).toFixed(1);
|
||||||
|
return { i, viewBox: `${vb.x} ${vb.y} ${vb.width} ${vb.height}`, clipped, overflow, above, below, offBy: +(above - below).toFixed(1) };
|
||||||
|
});
|
||||||
|
}, selector);
|
||||||
|
console.table(rows);
|
||||||
|
console.log('any clipped:', rows.some((r) => r.clipped));
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
/* Does the panel start at the title and end with the list, and hold that box across steps? */
|
||||||
|
const [,, url, prefix, width = '1440', height = '900'] = process.argv;
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle' });
|
||||||
|
const count = await page.evaluate(() => document.querySelectorAll('[data-trigger]').length);
|
||||||
|
const rows = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
await page.evaluate((i) => {
|
||||||
|
const t = document.querySelectorAll('[data-trigger]')[i];
|
||||||
|
const r = t.getBoundingClientRect();
|
||||||
|
window.scrollTo(0, window.scrollY + r.top + r.height / 2 - window.innerHeight / 2);
|
||||||
|
}, i);
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
rows.push(await page.evaluate(() => {
|
||||||
|
const title = document.querySelector('.how__title').getBoundingClientRect();
|
||||||
|
const steps = Array.from(document.querySelectorAll('[data-step]'));
|
||||||
|
const last = steps[steps.length - 1].getBoundingClientRect();
|
||||||
|
const fig = document.querySelector('[data-figure][data-active]').getBoundingClientRect();
|
||||||
|
const sticky = document.querySelector('.how__sticky').getBoundingClientRect();
|
||||||
|
const container = document.querySelector('.how__sticky');
|
||||||
|
const cs = getComputedStyle(container);
|
||||||
|
const rightEdge = sticky.right - parseFloat(cs.paddingRight);
|
||||||
|
return {
|
||||||
|
active: steps.findIndex((s) => s.hasAttribute('data-active')),
|
||||||
|
panel: `${Math.round(fig.width)}x${Math.round(fig.height)}`,
|
||||||
|
topVsTitle: Math.round(fig.top - title.top),
|
||||||
|
bottomVsList: Math.round(fig.bottom - last.bottom),
|
||||||
|
rightVsEdge: Math.round(fig.right - rightEdge),
|
||||||
|
listFits: Math.round(sticky.bottom - last.bottom),
|
||||||
|
spaceAbove: Math.round(fig.top - sticky.top),
|
||||||
|
spaceBelow: Math.round(sticky.bottom - fig.bottom),
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
await page.screenshot({ path: `${prefix}-${i}.png` });
|
||||||
|
}
|
||||||
|
console.table(rows);
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
/* Walks the pinned section step by step: which step is active, does the text column fit
|
||||||
|
inside the pinned area, and is the panel where it should be. */
|
||||||
|
const [,, url, prefix, width = '1440', height = '900'] = process.argv;
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle' });
|
||||||
|
const count = await page.evaluate(() => document.querySelectorAll('[data-trigger]').length);
|
||||||
|
const rows = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
await page.evaluate((i) => {
|
||||||
|
const t = document.querySelectorAll('[data-trigger]')[i];
|
||||||
|
const r = t.getBoundingClientRect();
|
||||||
|
window.scrollTo(0, window.scrollY + r.top + r.height / 2 - window.innerHeight / 2);
|
||||||
|
}, i);
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
const info = await page.evaluate(() => {
|
||||||
|
const steps = Array.from(document.querySelectorAll('[data-step]'));
|
||||||
|
const active = steps.findIndex((s) => s.hasAttribute('data-active'));
|
||||||
|
const sticky = document.querySelector('.how__sticky').getBoundingClientRect();
|
||||||
|
const text = document.querySelector('.how__text');
|
||||||
|
const first = text.getBoundingClientRect();
|
||||||
|
const last = steps[steps.length - 1].getBoundingClientRect();
|
||||||
|
const fig = document.querySelector('[data-figure][data-active]');
|
||||||
|
const f = fig ? fig.getBoundingClientRect() : null;
|
||||||
|
const svg = fig ? fig.querySelector('svg').getBoundingClientRect() : null;
|
||||||
|
return {
|
||||||
|
active,
|
||||||
|
textTop: Math.round(first.top - sticky.top),
|
||||||
|
spaceBelowList: Math.round(sticky.bottom - last.bottom),
|
||||||
|
panel: f ? `${Math.round(f.width)}x${Math.round(f.height)}` : null,
|
||||||
|
drawing: svg ? `${Math.round(svg.width)}x${Math.round(svg.height)}` : null,
|
||||||
|
panelInside: f ? (f.top >= sticky.top - 1 && f.bottom <= sticky.bottom + 1) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
rows.push({ step: i, ...info });
|
||||||
|
await page.screenshot({ path: `${prefix}-${i}.png` });
|
||||||
|
}
|
||||||
|
console.table(rows);
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
/* Scrolls to positions around each deck card's snap point and reports where the page comes to rest. */
|
||||||
|
const [,, url, width = '1440', height = '900'] = process.argv;
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle' });
|
||||||
|
const info = await page.evaluate(() => {
|
||||||
|
const cards = Array.from(document.querySelectorAll('[data-deck-snap]'));
|
||||||
|
const cs = getComputedStyle(document.documentElement);
|
||||||
|
return cards.map((c, i) => {
|
||||||
|
const st = getComputedStyle(c);
|
||||||
|
return { i, naturalTop: c.offsetTop + (c.offsetParent?.getBoundingClientRect().top ?? 0) + window.scrollY, marginTop: st.scrollMarginTop, snapAlign: st.scrollSnapAlign, snapStop: st.scrollSnapStop, htmlSnap: cs.scrollSnapType };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(info, null, 1));
|
||||||
|
// expected snap points: naturalTop - scrollMarginTop
|
||||||
|
const results = [];
|
||||||
|
for (const c of info) {
|
||||||
|
const expected = Math.round(c.naturalTop - parseFloat(c.marginTop));
|
||||||
|
for (const off of [-220, 180, 320, 352]) {
|
||||||
|
await page.evaluate((y) => window.scrollTo({ top: y, behavior: 'auto' }), expected + off);
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
const y = await page.evaluate(() => Math.round(window.scrollY));
|
||||||
|
results.push({ card: c.i, expected, offset: off, landed: y, snapped: Math.abs(y - expected) < 2 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.table(results);
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
const [,, url, prefix, anchor, width = '1440', height = '900', step = '450', count = '10'] = process.argv;
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle' });
|
||||||
|
const start = await page.evaluate((sel) => document.querySelector(sel).getBoundingClientRect().top + window.scrollY, anchor);
|
||||||
|
for (let i = 0; i < Number(count); i++) {
|
||||||
|
await page.evaluate((y) => window.scrollTo(0, y), start + i * Number(step));
|
||||||
|
await page.waitForTimeout(i === 0 ? 3000 : 1800);
|
||||||
|
await page.screenshot({ path: `${prefix}-${String(i).padStart(2, '0')}.png` });
|
||||||
|
}
|
||||||
|
await browser.close();
|
||||||
|
console.log('stack shots', count);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
const [,, url, prefix, anchor, width = '1440', height = '900', step = '450', count = '10'] = process.argv;
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle' });
|
||||||
|
const start = await page.evaluate((sel) => document.querySelector(sel).getBoundingClientRect().top + window.scrollY, anchor);
|
||||||
|
for (let i = 0; i < Number(count); i++) {
|
||||||
|
await page.evaluate((y) => window.scrollTo(0, y), start + i * Number(step));
|
||||||
|
await page.waitForTimeout(i === 0 ? 2500 : 1500);
|
||||||
|
await page.screenshot({ path: `${prefix}-${String(i).padStart(2, '0')}.png` });
|
||||||
|
}
|
||||||
|
await browser.close();
|
||||||
|
console.log('stack shots', count);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
/* Scrolls from an anchor downwards in steps, capturing the viewport each time. */
|
||||||
|
const [,, url, prefix, anchor, width = '1440', height = '900', step = '450', count = '10'] = process.argv;
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle' });
|
||||||
|
const start = await page.evaluate((sel) => document.querySelector(sel).getBoundingClientRect().top + window.scrollY, anchor);
|
||||||
|
for (let i = 0; i < Number(count); i++) {
|
||||||
|
await page.evaluate((y) => window.scrollTo(0, y), start + i * Number(step));
|
||||||
|
await page.waitForTimeout(i === 0 ? 3000 : 1800);
|
||||||
|
await page.screenshot({ path: `${prefix}-${String(i).padStart(2, '0')}.png` });
|
||||||
|
}
|
||||||
|
await browser.close();
|
||||||
|
console.log('stack shots', count);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
/* Does the pinned panel hold from the first step to the last, and let go cleanly after? */
|
||||||
|
const [,, url, prefix, width = '1440', height = '900'] = process.argv;
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle' });
|
||||||
|
const geo = await page.evaluate(() => {
|
||||||
|
const sc = document.querySelector('.how__scroller').getBoundingClientRect();
|
||||||
|
return { top: Math.round(sc.top + window.scrollY), height: Math.round(sc.height) };
|
||||||
|
});
|
||||||
|
const marks = { 'enter': geo.top, 'mid': geo.top + geo.height / 2, 'lastStep': geo.top + geo.height - (window?.innerHeight ?? 900), 'exit': geo.top + geo.height - 200 };
|
||||||
|
for (const [name, y] of Object.entries(marks)) {
|
||||||
|
await page.evaluate((y) => window.scrollTo(0, y), y);
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
const info = await page.evaluate(() => {
|
||||||
|
const st = document.querySelector('.how__sticky').getBoundingClientRect();
|
||||||
|
const active = Array.from(document.querySelectorAll('[data-step]')).findIndex((s) => s.hasAttribute('data-active'));
|
||||||
|
return { stickyTop: Math.round(st.top), stickyBottom: Math.round(st.bottom), active };
|
||||||
|
});
|
||||||
|
console.log(name.padEnd(9), JSON.stringify(info));
|
||||||
|
await page.screenshot({ path: `${prefix}-${name}.png` });
|
||||||
|
}
|
||||||
|
console.log('scroller', JSON.stringify(geo));
|
||||||
|
await browser.close();
|
||||||
@@ -4,21 +4,61 @@
|
|||||||
* Single stroke in currentColor, even width, no fills. The geometry is computed
|
* Single stroke in currentColor, even width, no fills. The geometry is computed
|
||||||
* at build time so the markup stays small and the stroke stays exact.
|
* at build time so the markup stays small and the stroke stays exact.
|
||||||
* Kinds: sphere (a model seen from every side), cube (a built structure),
|
* Kinds: sphere (a model seen from every side), cube (a built structure),
|
||||||
* plane (one element lifted out for inspection), wave (a signal over time).
|
* plane (one element lifted out for inspection), wave (a signal over time),
|
||||||
|
* steps (a ranked row of columns), nested (a structure inside its own walls),
|
||||||
|
* stack (layers kept one on top of the other).
|
||||||
*/
|
*/
|
||||||
interface Props { kind: 'sphere' | 'cube' | 'plane' | 'wave'; class?: string }
|
interface Props { kind: 'sphere' | 'cube' | 'plane' | 'wave' | 'steps' | 'nested' | 'stack'; class?: string }
|
||||||
const { kind, class: className } = Astro.props;
|
const { kind, class: className } = Astro.props;
|
||||||
|
|
||||||
const S = 400;
|
const S = 400;
|
||||||
const f = (n: number) => Math.round(n * 10) / 10;
|
const f = (n: number) => Math.round(n * 10) / 10;
|
||||||
const poly = (pts: [number, number][]) => pts.map(([x, y]) => `${f(x)},${f(y)}`).join(' ');
|
|
||||||
|
/* Every drawing is laid out by hand, so the box is derived from the geometry rather than
|
||||||
|
assumed: each point is recorded as it is emitted and the viewBox follows at the end. That
|
||||||
|
way a drawing can never be clipped, whatever a new kind does. */
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
const track = (x: number, y: number) => {
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
};
|
||||||
|
const poly = (pts: [number, number][]) => {
|
||||||
|
for (const [x, y] of pts) track(x, y);
|
||||||
|
return pts.map(([x, y]) => `${f(x)},${f(y)}`).join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
/* Isometric projection of a 3D point (x right, y up, z towards the viewer's left). */
|
/* Isometric projection of a 3D point (x right, y up, z towards the viewer's left). */
|
||||||
const ISO = { cx: S / 2, cy: S / 2, c30: Math.cos(Math.PI / 6), s30: Math.sin(Math.PI / 6) };
|
const ISO = { cx: S / 2, cy: S / 2, c30: Math.cos(Math.PI / 6), s30: Math.sin(Math.PI / 6) };
|
||||||
const iso = (x: number, y: number, z: number, cy = ISO.cy): [number, number] =>
|
const iso = (x: number, y: number, z: number, cy = ISO.cy, cx = ISO.cx): [number, number] =>
|
||||||
[ISO.cx + (x - z) * ISO.c30, cy + (x + z) * ISO.s30 - y];
|
[cx + (x - z) * ISO.c30, cy + (x + z) * ISO.s30 - y];
|
||||||
|
|
||||||
const lines: string[] = [];
|
/* The twelve edges of a cuboid with its near-bottom corner at (x0, y0, z0). */
|
||||||
|
function cuboid(x0: number, y0: number, z0: number, L: number, H: number, D: number, cy: number, cx = ISO.cx): string[] {
|
||||||
|
const p = (x: number, y: number, z: number) => iso(x, y, z, cy, cx);
|
||||||
|
const c = [
|
||||||
|
p(x0, y0, z0), p(x0 + L, y0, z0), p(x0 + L, y0, z0 + D), p(x0, y0, z0 + D),
|
||||||
|
p(x0, y0 + H, z0), p(x0 + L, y0 + H, z0), p(x0 + L, y0 + H, z0 + D), p(x0, y0 + H, z0 + D),
|
||||||
|
];
|
||||||
|
const e: [number, number][] = [[0, 1], [1, 2], [2, 3], [3, 0], [4, 5], [5, 6], [6, 7], [7, 4], [0, 4], [1, 5], [2, 6], [3, 7]];
|
||||||
|
return e.map(([a, b]) => poly([c[a], c[b]]));
|
||||||
|
}
|
||||||
|
/* A square grid plane of n cells at height y. */
|
||||||
|
function plane(n: number, u: number, y: number, cy: number, cx = ISO.cx): string[] {
|
||||||
|
const L = n * u, out: string[] = [];
|
||||||
|
for (let i = 0; i <= n; i++) {
|
||||||
|
const t = i * u;
|
||||||
|
out.push(poly([iso(t, y, 0, cy, cx), iso(t, y, L, cy, cx)]));
|
||||||
|
out.push(poly([iso(0, y, t, cy, cx), iso(L, y, t, cy, cx)]));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Each drawing is split in two: the lines that carry its idea, and the grid around them. A
|
||||||
|
hover lets the grid recede so the idea stands on its own. */
|
||||||
|
const base: string[] = [];
|
||||||
|
const accent: string[] = [];
|
||||||
|
|
||||||
if (kind === 'sphere') {
|
if (kind === 'sphere') {
|
||||||
const R = 150, tilt = (22 * Math.PI) / 180, N = 48;
|
const R = 150, tilt = (22 * Math.PI) / 180, N = 48;
|
||||||
@@ -29,35 +69,35 @@ if (kind === 'sphere') {
|
|||||||
const y2 = y * Math.cos(tilt) - z * Math.sin(tilt);
|
const y2 = y * Math.cos(tilt) - z * Math.sin(tilt);
|
||||||
return [ISO.cx + x, ISO.cy - y2];
|
return [ISO.cx + x, ISO.cy - y2];
|
||||||
};
|
};
|
||||||
// Parallels every 20 degrees, meridians every 22.5 degrees (8 great circles).
|
|
||||||
for (let d = -80; d <= 80; d += 20) {
|
for (let d = -80; d <= 80; d += 20) {
|
||||||
const lat = (d * Math.PI) / 180;
|
const lat = (d * Math.PI) / 180;
|
||||||
const pts: [number, number][] = [];
|
const pts: [number, number][] = [];
|
||||||
for (let i = 0; i <= N; i++) pts.push(p3(lat, (i / N) * 2 * Math.PI));
|
for (let i = 0; i <= N; i++) pts.push(p3(lat, (i / N) * 2 * Math.PI));
|
||||||
lines.push(poly(pts));
|
(d === 0 ? accent : base).push(poly(pts));
|
||||||
}
|
}
|
||||||
for (let m = 0; m < 8; m++) {
|
for (let m = 0; m < 8; m++) {
|
||||||
const lon = (m * Math.PI) / 8;
|
const lon = (m * Math.PI) / 8;
|
||||||
const pts: [number, number][] = [];
|
const pts: [number, number][] = [];
|
||||||
for (let i = 0; i <= N; i++) pts.push(p3((i / N) * 2 * Math.PI - Math.PI, lon));
|
for (let i = 0; i <= N; i++) pts.push(p3((i / N) * 2 * Math.PI - Math.PI, lon));
|
||||||
lines.push(poly(pts));
|
(m === 0 ? accent : base).push(poly(pts));
|
||||||
}
|
}
|
||||||
lines.push(`circle:${R}`);
|
accent.push(`circle:${R}`);
|
||||||
|
track(ISO.cx - R, ISO.cy - R);
|
||||||
|
track(ISO.cx + R, ISO.cy + R);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === 'cube') {
|
if (kind === 'cube') {
|
||||||
const n = 4, u = 46, L = n * u, cy = ISO.cy + L * 0.5;
|
const n = 4, u = 46, L = n * u, cy = ISO.cy + L * 0.5;
|
||||||
for (let i = 0; i <= n; i++) {
|
for (let i = 0; i <= n; i++) {
|
||||||
const t = i * u;
|
const t = i * u;
|
||||||
// top face (y = L)
|
/* The first and last line of each face are the cube's own edges. */
|
||||||
lines.push(poly([iso(t, L, 0, cy), iso(t, L, L, cy)]));
|
const target = i === 0 || i === n ? accent : base;
|
||||||
lines.push(poly([iso(0, L, t, cy), iso(L, L, t, cy)]));
|
target.push(poly([iso(t, L, 0, cy), iso(t, L, L, cy)]));
|
||||||
// front-right face (z = 0): x by y
|
target.push(poly([iso(0, L, t, cy), iso(L, L, t, cy)]));
|
||||||
lines.push(poly([iso(t, 0, 0, cy), iso(t, L, 0, cy)]));
|
target.push(poly([iso(t, 0, 0, cy), iso(t, L, 0, cy)]));
|
||||||
lines.push(poly([iso(0, t, 0, cy), iso(L, t, 0, cy)]));
|
target.push(poly([iso(0, t, 0, cy), iso(L, t, 0, cy)]));
|
||||||
// front-left face (x = 0): z by y
|
target.push(poly([iso(0, 0, t, cy), iso(0, L, t, cy)]));
|
||||||
lines.push(poly([iso(0, 0, t, cy), iso(0, L, t, cy)]));
|
target.push(poly([iso(0, t, 0, cy), iso(0, t, L, cy)]));
|
||||||
lines.push(poly([iso(0, t, 0, cy), iso(0, t, L, cy)]));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,37 +105,89 @@ if (kind === 'plane') {
|
|||||||
const n = 6, u = 34, L = n * u, cy = ISO.cy + 20, h = 96, tx = 2, tz = 2;
|
const n = 6, u = 34, L = n * u, cy = ISO.cy + 20, h = 96, tx = 2, tz = 2;
|
||||||
for (let i = 0; i <= n; i++) {
|
for (let i = 0; i <= n; i++) {
|
||||||
const t = i * u;
|
const t = i * u;
|
||||||
lines.push(poly([iso(t, 0, 0, cy), iso(t, 0, L, cy)]));
|
base.push(poly([iso(t, 0, 0, cy), iso(t, 0, L, cy)]));
|
||||||
lines.push(poly([iso(0, 0, t, cy), iso(L, 0, t, cy)]));
|
base.push(poly([iso(0, 0, t, cy), iso(L, 0, t, cy)]));
|
||||||
}
|
}
|
||||||
|
/* The tile that has been lifted out for inspection. */
|
||||||
const c: [number, number][] = [[tx, tz], [tx + 1, tz], [tx + 1, tz + 1], [tx, tz + 1]];
|
const c: [number, number][] = [[tx, tz], [tx + 1, tz], [tx + 1, tz + 1], [tx, tz + 1]];
|
||||||
const top = c.map(([x, z]) => iso(x * u, h, z * u, cy));
|
const top = c.map(([x, z]) => iso(x * u, h, z * u, cy));
|
||||||
lines.push(poly([...top, top[0]]));
|
accent.push(poly([...top, top[0]]));
|
||||||
c.forEach(([x, z], i) => lines.push(poly([iso(x * u, 0, z * u, cy), top[i]])));
|
c.forEach(([x, z], i) => accent.push(poly([iso(x * u, 0, z * u, cy), top[i]])));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === 'wave') {
|
if (kind === 'wave') {
|
||||||
const nx = 12, nz = 8, u = 26, cy = ISO.cy + 10, A = 26;
|
const nx = 12, nz = 8, u = 26, cy = ISO.cy + 10, A = 26;
|
||||||
const yAt = (x: number, z: number) => A * Math.sin((x / nx) * Math.PI * 2 - 0.6) * Math.cos((z / nz) * Math.PI * 0.9);
|
const yAt = (x: number, z: number) => A * Math.sin((x / nx) * Math.PI * 2 - 0.6) * Math.cos((z / nz) * Math.PI * 0.9);
|
||||||
const P = (x: number, z: number) => iso((x - nx / 2) * u, yAt(x, z), (z - nz / 2) * u, cy);
|
const P = (x: number, z: number) => iso((x - nx / 2) * u, yAt(x, z), (z - nz / 2) * u, cy);
|
||||||
|
/* The lines along x carry the wave itself; the cross lines are the grid it is read against. */
|
||||||
for (let z = 0; z <= nz; z++) {
|
for (let z = 0; z <= nz; z++) {
|
||||||
const pts: [number, number][] = [];
|
const pts: [number, number][] = [];
|
||||||
for (let x = 0; x <= nx; x++) pts.push(P(x, z));
|
for (let x = 0; x <= nx; x++) pts.push(P(x, z));
|
||||||
lines.push(poly(pts));
|
accent.push(poly(pts));
|
||||||
}
|
}
|
||||||
for (let x = 0; x <= nx; x++) {
|
for (let x = 0; x <= nx; x++) {
|
||||||
const pts: [number, number][] = [];
|
const pts: [number, number][] = [];
|
||||||
for (let z = 0; z <= nz; z++) pts.push(P(x, z));
|
for (let z = 0; z <= nz; z++) pts.push(P(x, z));
|
||||||
lines.push(poly(pts));
|
base.push(poly(pts));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (kind === 'steps') {
|
||||||
|
/* Five columns in a row, tallest first: a ranked list seen as a skyline. */
|
||||||
|
const heights = [168, 128, 96, 68, 44], w = 40, gap = 14, depth = 40, cy = ISO.cy + 78;
|
||||||
|
const total = heights.length * w + (heights.length - 1) * gap;
|
||||||
|
heights.forEach((h, i) => {
|
||||||
|
const x0 = -total / 2 + i * (w + gap);
|
||||||
|
/* The first column is the one to fix first. */
|
||||||
|
cuboid(x0, 0, -depth / 2, w, h, depth, cy).forEach((l) => (i === 0 ? accent : base).push(l));
|
||||||
|
});
|
||||||
|
const L = total + 48;
|
||||||
|
base.push(poly([iso(-L / 2, 0, -depth / 2 - 24, cy), iso(L / 2, 0, -depth / 2 - 24, cy)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'nested') {
|
||||||
|
/* A structure inside its own walls: a cube inside a cube, corners tied together. */
|
||||||
|
const O = 200, I = 96, cy = ISO.cy + 84;
|
||||||
|
cuboid(-O / 2, 0, -O / 2, O, O, O, cy).forEach((l) => base.push(l));
|
||||||
|
const off = (O - I) / 2;
|
||||||
|
cuboid(-I / 2, off, -I / 2, I, I, I, cy).forEach((l) => accent.push(l));
|
||||||
|
const outer = [[-O / 2, 0, -O / 2], [O / 2, 0, -O / 2], [O / 2, 0, O / 2], [-O / 2, 0, O / 2], [-O / 2, O, -O / 2], [O / 2, O, -O / 2], [O / 2, O, O / 2], [-O / 2, O, O / 2]];
|
||||||
|
const inner = [[-I / 2, off, -I / 2], [I / 2, off, -I / 2], [I / 2, off, I / 2], [-I / 2, off, I / 2], [-I / 2, off + I, -I / 2], [I / 2, off + I, -I / 2], [I / 2, off + I, I / 2], [-I / 2, off + I, I / 2]];
|
||||||
|
outer.forEach(([x, y, z], k) => base.push(poly([iso(x, y, z, cy), iso(inner[k][0], inner[k][1], inner[k][2], cy)])));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'stack') {
|
||||||
|
/* Layers one on top of the other, each a record kept whole. */
|
||||||
|
const n = 4, u = 38, L = n * u, gapY = 46, layers = 4, cy = ISO.cy + 60;
|
||||||
|
/* The newest layer sits on top of the ones already kept. */
|
||||||
|
for (let k = 0; k < layers; k++) {
|
||||||
|
plane(n, u, k * gapY, cy).forEach((l) => (k === layers - 1 ? accent : base).push(l));
|
||||||
|
}
|
||||||
|
const corners: [number, number][] = [[0, 0], [L, 0], [L, L], [0, L]];
|
||||||
|
corners.forEach(([x, z]) => base.push(poly([iso(x, 0, z, cy), iso(x, (layers - 1) * gapY, z, cy)])));
|
||||||
|
}
|
||||||
|
/* A square box centred on the drawing: wide drawings still fill the width, and every object
|
||||||
|
keeps the same footprint, so the four cards of a row stay even. The padding covers the
|
||||||
|
stroke, which sits half outside the geometry. */
|
||||||
|
const PAD = 8;
|
||||||
|
const width = maxX - minX;
|
||||||
|
const height = maxY - minY;
|
||||||
|
const side = Math.max(width, height) + PAD * 2;
|
||||||
|
const viewBox = [minX + width / 2 - side / 2, minY + height / 2 - side / 2, side, side].map(f).join(' ');
|
||||||
---
|
---
|
||||||
<svg class:list={['grid-object', className]} viewBox={`0 0 ${S} ${S}`} aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
|
<svg class:list={['grid-object', className]} viewBox={viewBox} aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
|
||||||
<g fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke">
|
<g class="grid-object__lines" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke">
|
||||||
{lines.map((l) => l.startsWith('circle:')
|
<g class="grid-object__base">
|
||||||
|
{base.map((l) => l.startsWith('circle:')
|
||||||
? <circle cx={S / 2} cy={S / 2} r={Number(l.slice(7))} />
|
? <circle cx={S / 2} cy={S / 2} r={Number(l.slice(7))} />
|
||||||
: <polyline points={l} />)}
|
: <polyline points={l} />)}
|
||||||
</g>
|
</g>
|
||||||
|
<g class="grid-object__accent">
|
||||||
|
{accent.map((l) => l.startsWith('circle:')
|
||||||
|
? <circle cx={S / 2} cy={S / 2} r={Number(l.slice(7))} />
|
||||||
|
: <polyline points={l} />)}
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -103,5 +195,14 @@ if (kind === 'wave') {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
|
/* Both are driven by the card around it, so the card owns the interaction and the drawing
|
||||||
|
owns how it answers. */
|
||||||
|
transform: scale(var(--object-scale, 1));
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
transition: transform var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
.grid-object__base {
|
||||||
|
opacity: var(--object-grid, 1);
|
||||||
|
transition: opacity var(--dur) var(--ease);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -9,6 +9,8 @@ const ui = t(lang);
|
|||||||
---
|
---
|
||||||
<section class="hero night" aria-labelledby="hero-title">
|
<section class="hero night" aria-labelledby="hero-title">
|
||||||
<div class="hero__media">
|
<div class="hero__media">
|
||||||
|
<!-- The still is the video's first frame, so it carries the hero until the video is ready
|
||||||
|
and stays put for anyone who never gets one: no script, reduced motion, saved data. -->
|
||||||
<Picture
|
<Picture
|
||||||
src={plant}
|
src={plant}
|
||||||
alt={ui.hero.imageAlt}
|
alt={ui.hero.imageAlt}
|
||||||
@@ -22,6 +24,18 @@ const ui = t(lang);
|
|||||||
class="hero__img img-cover img-doc"
|
class="hero__img img-cover img-doc"
|
||||||
pictureAttributes={{ class: 'hero__picture' }}
|
pictureAttributes={{ class: 'hero__picture' }}
|
||||||
/>
|
/>
|
||||||
|
<video
|
||||||
|
class="hero__video img-cover img-doc"
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsinline
|
||||||
|
disablepictureinpicture
|
||||||
|
preload="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
data-hero-video
|
||||||
|
data-mp4="/video/hero-plant.mp4"
|
||||||
|
data-webm="/video/hero-plant.webm"
|
||||||
|
></video>
|
||||||
<div class="hero__overlay"></div>
|
<div class="hero__overlay"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -39,7 +53,8 @@ const ui = t(lang);
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
min-height: clamp(30rem, calc(100svh - var(--header-height)), 56rem);
|
/* Fills the screen under the header, and never less than the headline needs. */
|
||||||
|
min-height: max(28rem, calc(100svh - var(--header-height)));
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.hero__media { position: absolute; inset: 0; }
|
.hero__media { position: absolute; inset: 0; }
|
||||||
@@ -53,6 +68,18 @@ const ui = t(lang);
|
|||||||
transform-origin: 50% 60%;
|
transform-origin: 50% 60%;
|
||||||
animation: hero-settle var(--dur-hero) var(--ease) both;
|
animation: hero-settle var(--dur-hero) var(--ease) both;
|
||||||
}
|
}
|
||||||
|
.hero__video {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: 60% 50%;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--dur-slow) var(--ease);
|
||||||
|
}
|
||||||
|
/* Set once the video can actually play, so the still never cuts to black. */
|
||||||
|
.hero__video[data-ready] { opacity: 1; }
|
||||||
.hero__overlay {
|
.hero__overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -82,5 +109,32 @@ const ui = t(lang);
|
|||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.hero__img, :global(.hero__img), .hero__line { animation: none; opacity: 1; transform: none; }
|
.hero__img, :global(.hero__img), .hero__line { animation: none; opacity: 1; transform: none; }
|
||||||
|
.hero__video { display: none; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* The sources are attached here rather than in the markup, so the file is only fetched for
|
||||||
|
someone who will actually see it move: not under reduced motion, not on saved data, and not
|
||||||
|
on a phone, where a 21:9 frame is cropped to a sliver and the bytes cost the most. */
|
||||||
|
const video = document.querySelector<HTMLVideoElement>('[data-hero-video]');
|
||||||
|
const wants = window.matchMedia('(min-width: 48rem)').matches
|
||||||
|
&& !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
const connection = (navigator as Navigator & { connection?: { saveData?: boolean } }).connection;
|
||||||
|
if (video && wants && !connection?.saveData) {
|
||||||
|
const add = (src: string | null, type: string) => {
|
||||||
|
if (!src) return;
|
||||||
|
const source = document.createElement('source');
|
||||||
|
source.src = src;
|
||||||
|
source.type = type;
|
||||||
|
video.append(source);
|
||||||
|
};
|
||||||
|
add(video.dataset.webm ?? null, 'video/webm');
|
||||||
|
add(video.dataset.mp4 ?? null, 'video/mp4');
|
||||||
|
video.addEventListener('canplay', () => video.setAttribute('data-ready', ''), { once: true });
|
||||||
|
video.preload = 'auto';
|
||||||
|
video.load();
|
||||||
|
/* A blocked autoplay leaves the still in place, which is a fine hero on its own. */
|
||||||
|
video.play().catch(() => {});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -30,8 +30,8 @@ const objects = ['sphere', 'cube', 'plane', 'wave'] as const;
|
|||||||
.stages__item {
|
.stages__item {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
grid-template-rows: auto auto 1fr;
|
/* Title, then the drawing in the row that takes the slack, then the body at the bottom. */
|
||||||
align-items: start;
|
grid-template-rows: auto 1fr auto;
|
||||||
gap: var(--space-6);
|
gap: var(--space-6);
|
||||||
padding: var(--space-6) var(--space-6) var(--space-7);
|
padding: var(--space-6) var(--space-6) var(--space-7);
|
||||||
background: var(--paper-deep);
|
background: var(--paper-deep);
|
||||||
@@ -52,6 +52,13 @@ const objects = ['sphere', 'cube', 'plane', 'wave'] as const;
|
|||||||
justify-self: center;
|
justify-self: center;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
/* Hovering a card lets its grid recede, so the shape that carries the idea stands alone. */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.stages__item:hover {
|
||||||
|
--object-scale: 1.03;
|
||||||
|
--object-grid: 0.26;
|
||||||
|
}
|
||||||
|
}
|
||||||
.stages__body { max-width: 24em; }
|
.stages__body { max-width: 24em; }
|
||||||
@media (min-width: 48rem) {
|
@media (min-width: 48rem) {
|
||||||
.stages__list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.stages__list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { t, type Lang } from '../../i18n/ui';
|
|||||||
interface Props { lang: Lang }
|
interface Props { lang: Lang }
|
||||||
const { lang } = Astro.props;
|
const { lang } = Astro.props;
|
||||||
const ui = t(lang);
|
const ui = t(lang);
|
||||||
const objects = ['plane', 'cube', 'wave'] as const;
|
const objects = ['steps', 'nested', 'stack'] as const;
|
||||||
---
|
---
|
||||||
<section class="section vcards" aria-label={lang === 'de' ? 'Eigenschaften' : 'Properties'}>
|
<section class="section vcards" aria-label={lang === 'de' ? 'Eigenschaften' : 'Properties'}>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -25,8 +25,8 @@ const objects = ['plane', 'cube', 'wave'] as const;
|
|||||||
.vcards__item {
|
.vcards__item {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
grid-template-rows: auto auto 1fr;
|
/* Title, then the drawing in the row that takes the slack, then the body at the bottom. */
|
||||||
align-items: start;
|
grid-template-rows: auto 1fr auto;
|
||||||
gap: var(--space-6);
|
gap: var(--space-6);
|
||||||
padding: var(--space-6) var(--space-6) var(--space-7);
|
padding: var(--space-6) var(--space-6) var(--space-7);
|
||||||
background: var(--paper-deep);
|
background: var(--paper-deep);
|
||||||
@@ -40,7 +40,14 @@ const objects = ['plane', 'cube', 'wave'] as const;
|
|||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
hyphens: auto;
|
hyphens: auto;
|
||||||
}
|
}
|
||||||
.vcards__item :global(.vcards__object) { width: 100%; max-width: 14rem; justify-self: center; }
|
.vcards__item :global(.vcards__object) { width: 100%; max-width: 14rem; justify-self: center; align-self: center; }
|
||||||
|
/* Hovering a card lets its grid recede, so the shape that carries the idea stands alone. */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.vcards__item:hover {
|
||||||
|
--object-scale: 1.03;
|
||||||
|
--object-grid: 0.26;
|
||||||
|
}
|
||||||
|
}
|
||||||
.vcards__body { max-width: 26em; }
|
.vcards__body { max-width: 26em; }
|
||||||
@media (min-width: 48rem) {
|
@media (min-width: 48rem) {
|
||||||
.vcards__list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
.vcards__list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ const ins = Array.from({ length: n }, (_, i) => {
|
|||||||
<svg class="vhero__burst" viewBox={`0 0 ${W} ${H}`} role="img" aria-label={v.burstDesc} xmlns="http://www.w3.org/2000/svg">
|
<svg class="vhero__burst" viewBox={`0 0 ${W} ${H}`} role="img" aria-label={v.burstDesc} xmlns="http://www.w3.org/2000/svg">
|
||||||
<g fill="none" stroke-width="1" vector-effect="non-scaling-stroke">
|
<g fill="none" stroke-width="1" vector-effect="non-scaling-stroke">
|
||||||
<g class="vhero__in">
|
<g class="vhero__in">
|
||||||
{ins.map((p) => <line x1={p.split(',')[0]} y1={p.split(',')[1]} x2={F[0]} y2={F[1]} />)}
|
{ins.map((p, i) => <line x1={p.split(',')[0]} y1={p.split(',')[1]} x2={F[0]} y2={F[1]} pathLength="1" style={`--i: ${i}`} />)}
|
||||||
</g>
|
</g>
|
||||||
<line class="vhero__out" x1={F[0]} y1={F[1]} x2={W} y2={F[1]} stroke-width="1.25" />
|
<line class="vhero__out" x1={F[0]} y1={F[1]} x2={W} y2={F[1]} stroke-width="1.25" pathLength="1" />
|
||||||
<circle class="vhero__ring" cx={F[0]} cy={F[1]} r="40" stroke-dasharray="2 5" stroke-width="1.25" />
|
<circle class="vhero__ring" cx={F[0]} cy={F[1]} r="40" stroke-dasharray="2 5" stroke-width="1.25" />
|
||||||
</g>
|
</g>
|
||||||
<circle cx={F[0]} cy={F[1]} r="5" fill="currentColor" />
|
<circle class="vhero__dot" cx={F[0]} cy={F[1]} r="5" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<div class="vhero__text">
|
<div class="vhero__text">
|
||||||
@@ -58,6 +58,32 @@ const ins = Array.from({ length: n }, (_, i) => {
|
|||||||
}
|
}
|
||||||
.vhero__in { stroke: var(--ink-muted); opacity: 0.55; }
|
.vhero__in { stroke: var(--ink-muted); opacity: 0.55; }
|
||||||
.vhero__out, .vhero__ring { stroke: currentColor; }
|
.vhero__out, .vhero__ring { stroke: currentColor; }
|
||||||
|
|
||||||
|
/* The one orchestrated moment: the lines arrive, meet, and one line leaves. */
|
||||||
|
.vhero__in line {
|
||||||
|
stroke-dasharray: 1;
|
||||||
|
stroke-dashoffset: 1;
|
||||||
|
animation: vhero-draw 1100ms var(--ease) forwards;
|
||||||
|
animation-delay: calc(200ms + var(--i) * 40ms);
|
||||||
|
}
|
||||||
|
.vhero__ring, .vhero__dot {
|
||||||
|
opacity: 0;
|
||||||
|
animation: vhero-appear 500ms var(--ease) forwards;
|
||||||
|
animation-delay: 1250ms;
|
||||||
|
}
|
||||||
|
.vhero__dot { transform-box: fill-box; transform-origin: center; }
|
||||||
|
.vhero__out {
|
||||||
|
stroke-dasharray: 1;
|
||||||
|
stroke-dashoffset: 1;
|
||||||
|
animation: vhero-draw 900ms var(--ease) forwards;
|
||||||
|
animation-delay: 1500ms;
|
||||||
|
}
|
||||||
|
@keyframes vhero-draw { to { stroke-dashoffset: 0; } }
|
||||||
|
@keyframes vhero-appear { to { opacity: 1; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.vhero__in line, .vhero__out { animation: none; stroke-dashoffset: 0; }
|
||||||
|
.vhero__ring, .vhero__dot { animation: none; opacity: 1; }
|
||||||
|
}
|
||||||
.vhero__text {
|
.vhero__text {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
|
|||||||
@@ -9,112 +9,268 @@ const v = ui.verifier.how;
|
|||||||
const pairs = [builders[0](v.groups[0]), builders[1](v.groups[1]), builders[2](v.groups[2]), builders[3](v.groups[3]), builders[4](v.groups[4])];
|
const pairs = [builders[0](v.groups[0]), builders[1](v.groups[1]), builders[2](v.groups[2]), builders[3](v.groups[3]), builders[4](v.groups[4])];
|
||||||
const groups = v.groups.map((g, i) => ({ title: g.title, body: g.body, desc: g.desc, id: `how-${i + 1}`, pair: pairs[i] }));
|
const groups = v.groups.map((g, i) => ({ title: g.title, body: g.body, desc: g.desc, id: `how-${i + 1}`, pair: pairs[i] }));
|
||||||
---
|
---
|
||||||
<section class="section night how" id="how" aria-labelledby="how-title">
|
<section class="night how" id="how" aria-labelledby="how-title">
|
||||||
<div class="container how__grid" data-how>
|
<div class="how__scroller" style={`--how-steps: ${groups.length}`} data-how>
|
||||||
|
<div class="how__sticky container">
|
||||||
|
<div class="how__layout">
|
||||||
<div class="how__text">
|
<div class="how__text">
|
||||||
<h2 id="how-title" class="h2">{v.title}</h2>
|
<div class="how__text-inner">
|
||||||
<div class="how__tabs" role="tablist" aria-orientation="vertical" aria-labelledby="how-title">
|
<h2 id="how-title" class="h2 how__title">{v.title}</h2>
|
||||||
|
|
||||||
|
<ol class="how__steps" role="list">
|
||||||
{groups.map((g, i) => (
|
{groups.map((g, i) => (
|
||||||
<div class="how__tab">
|
<li class="how__step" id={g.id} data-step data-active={i === 0 ? '' : undefined}>
|
||||||
<h3 class="how__heading">
|
<h3 class="how__step-heading">
|
||||||
<button class="how__button" type="button" role="tab" id={`${g.id}-tab`} aria-controls={`${g.id}-panel`} aria-selected={i === 0 ? 'true' : 'false'} tabindex={i === 0 ? undefined : -1}>{g.title}</button>
|
<button class="how__step-button" type="button" data-step-to={i} aria-current={i === 0 ? 'step' : undefined}>{g.title}</button>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="how__panel" role="tabpanel" id={`${g.id}-panel`} aria-labelledby={`${g.id}-tab`} tabindex="0">
|
<div class="how__step-body">
|
||||||
<div class="how__panel-inner">
|
<div class="how__step-inner">
|
||||||
<p class="muted">{g.body}</p>
|
<p class="muted">{g.body}</p>
|
||||||
<div class="how__tall">
|
<div class="how__tall">
|
||||||
<Schematic layout={g.pair.tall} title={g.title} desc={g.desc} variant="tall" id={`${g.id}-tall`} />
|
<Schematic layout={g.pair.tall} title={g.title} desc={g.desc} variant="tall" id={`${g.id}-tall`} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- On wide screens this sits in the panel on the right, one drawing per step. -->
|
||||||
))}
|
<div class="how__figure" data-figure data-active={i === 0 ? '' : undefined}>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="how__stage">
|
|
||||||
{groups.map((g, i) => (
|
|
||||||
<div class="how__figure" data-stage-item data-active={i === 0 ? '' : undefined}>
|
|
||||||
<Schematic layout={g.pair.wide} title={g.title} desc={g.desc} variant="wide" id={`${g.id}-wide`} />
|
<Schematic layout={g.pair.wide} title={g.title} desc={g.desc} variant="wide" id={`${g.id}-wide`} />
|
||||||
</div>
|
</div>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- One trigger per step, tiling the scroller: whichever crosses the middle of the
|
||||||
|
viewport is the step being read. -->
|
||||||
|
<div class="how__triggers" aria-hidden="true">
|
||||||
|
{groups.map(() => <div class="how__trigger" data-trigger></div>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.how__grid {
|
/* Narrow screens: a plain sequence, every step open, its drawing under its text. */
|
||||||
display: grid;
|
.how { padding-block: var(--section); }
|
||||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
.how__title { max-width: 12em; }
|
||||||
column-gap: var(--gap);
|
.how__steps { margin-top: var(--space-8); }
|
||||||
row-gap: var(--space-8);
|
.how__step {
|
||||||
|
padding-block: var(--space-7) var(--space-8);
|
||||||
|
border-top: var(--hairline) solid var(--line);
|
||||||
|
scroll-margin-top: var(--header-height);
|
||||||
}
|
}
|
||||||
.how__text {
|
.how__step-heading {
|
||||||
grid-column: 1 / -1;
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-8);
|
|
||||||
}
|
|
||||||
.how__tab + .how__tab { margin-top: var(--space-5); }
|
|
||||||
.how__heading { font-size: inherit; font-weight: inherit; }
|
|
||||||
.how__button {
|
|
||||||
display: block;
|
|
||||||
text-align: left;
|
|
||||||
font-size: var(--step-h3);
|
font-size: var(--step-h3);
|
||||||
line-height: var(--leading-h3);
|
line-height: var(--leading-h3);
|
||||||
letter-spacing: var(--track-h3);
|
letter-spacing: var(--track-h3);
|
||||||
font-weight: var(--weight-medium);
|
font-weight: var(--weight-medium);
|
||||||
color: var(--ink);
|
|
||||||
transition: color var(--dur-fast) var(--ease);
|
|
||||||
}
|
}
|
||||||
.how__button[aria-selected='false'] { color: var(--ink-muted); }
|
.how__step-button {
|
||||||
.how__button[aria-selected='false']:hover { color: var(--ink); }
|
display: block;
|
||||||
.how__panel-inner { display: grid; gap: var(--space-6); padding-top: var(--space-3); }
|
text-align: left;
|
||||||
.how__panel p { max-width: 30em; }
|
font: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
color: inherit;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.how__step-inner { display: grid; gap: var(--space-6); padding-top: var(--space-4); }
|
||||||
|
.how__step-inner p { max-width: 34em; }
|
||||||
.how__tall { max-width: 26rem; }
|
.how__tall { max-width: 26rem; }
|
||||||
|
.how__figure { display: none; }
|
||||||
|
.how__triggers { display: none; }
|
||||||
|
|
||||||
/* The stage carries the wide drawings for wide screens; the panels carry the tall ones. */
|
/* Wide screens: the title and the panel hold still, the list advances as the page scrolls. */
|
||||||
.how__stage { display: none; }
|
|
||||||
|
|
||||||
:global(html.js) .how__panel {
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: 0fr;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: grid-template-rows var(--dur-slow) var(--ease);
|
|
||||||
}
|
|
||||||
:global(html.js) .how__panel-inner { min-height: 0; }
|
|
||||||
:global(html.js) .how__panel[data-open] { grid-template-rows: 1fr; }
|
|
||||||
|
|
||||||
@media (min-width: 64rem) {
|
@media (min-width: 64rem) {
|
||||||
/* Title left, the list right; the drawing takes the full width underneath. */
|
.how {
|
||||||
.how__text {
|
--how-pitch: 70svh;
|
||||||
grid-column: 1 / -1;
|
--how-inset: var(--space-7);
|
||||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
--how-panel-h: calc(100svh - var(--header-height));
|
||||||
column-gap: var(--gap);
|
padding-block: 0;
|
||||||
align-items: start;
|
|
||||||
}
|
}
|
||||||
.how__text > h2 { grid-column: 1 / span 5; }
|
/* One pitch of scrolling per step, plus one panel height so the panel stays pinned until
|
||||||
.how__tabs { grid-column: 6 / span 7; }
|
the last step has been read rather than letting go early. */
|
||||||
.how__stage {
|
.how__scroller {
|
||||||
grid-column: 1 / -1;
|
position: relative;
|
||||||
|
height: calc(var(--how-steps) * var(--how-pitch) + var(--how-panel-h));
|
||||||
|
}
|
||||||
|
.how__sticky {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--header-height);
|
||||||
|
height: var(--how-panel-h);
|
||||||
|
}
|
||||||
|
.how__layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
border: var(--hairline) solid var(--line);
|
grid-template-columns: 4fr 8fr;
|
||||||
padding: var(--space-7) var(--space-7);
|
column-gap: var(--gap);
|
||||||
|
height: 100%;
|
||||||
|
padding-block: var(--how-inset);
|
||||||
}
|
}
|
||||||
.how__figure {
|
.how__text {
|
||||||
grid-area: 1 / 1;
|
grid-column: 1;
|
||||||
align-self: center;
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
/* The box on the right is tied to this, so it ends where the text ends. Holding it at the
|
||||||
|
height of the longest step keeps both of them still as the steps change. */
|
||||||
|
.how__text-inner {
|
||||||
|
position: relative;
|
||||||
|
min-height: var(--how-text-h, auto);
|
||||||
|
}
|
||||||
|
.how__title { max-width: 14em; }
|
||||||
|
.how__steps { margin-top: clamp(1.5rem, 9vh, 5rem); }
|
||||||
|
.how__step {
|
||||||
|
padding-block: var(--space-3);
|
||||||
|
border-top: 0;
|
||||||
|
scroll-margin-top: 0;
|
||||||
|
}
|
||||||
|
.how__step-button {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
.how__step-button:hover { color: var(--ink); }
|
||||||
|
.how__step[data-active] .how__step-button { color: var(--ink); }
|
||||||
|
/* Only the step being read shows its text. */
|
||||||
|
.how__step-body {
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: height var(--dur-slow) var(--ease);
|
||||||
|
}
|
||||||
|
.how__step-inner {
|
||||||
|
min-height: 0;
|
||||||
|
gap: 0;
|
||||||
|
padding-top: var(--space-3);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
transition: opacity var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
.how__step-inner p {
|
||||||
|
font-size: var(--step-small);
|
||||||
|
line-height: var(--leading-small);
|
||||||
|
max-width: 32em;
|
||||||
|
}
|
||||||
|
.how__step[data-active] .how__step-body { height: auto; }
|
||||||
|
.how__step[data-active] .how__step-inner { opacity: 1; }
|
||||||
|
.how__tall { display: none; }
|
||||||
|
|
||||||
|
/* The panel: one box on the right, the drawings crossfading inside it. */
|
||||||
|
.how__figure {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: var(--how-text-h, 100%);
|
||||||
|
/* The text column is 4 of 12 and the panel 8, so the panel is twice its width and sits
|
||||||
|
one gap to its right. Stated against the text column, since that is what it is tied to. */
|
||||||
|
right: calc(-1 * (var(--gap) + 200%));
|
||||||
|
width: 200%;
|
||||||
|
padding: var(--space-6);
|
||||||
|
border: var(--hairline) solid var(--line);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
transition: opacity var(--dur-slow) var(--ease);
|
transition: opacity var(--dur-slow) var(--ease);
|
||||||
}
|
}
|
||||||
.how__figure[data-active] { opacity: 1; }
|
.how__figure[data-active] { opacity: 1; }
|
||||||
.how__tall { display: none; }
|
.how__figure :global(.schematic) { max-height: 100%; }
|
||||||
|
|
||||||
|
/* The triggers start half a viewport down, so the first step turns over exactly when the
|
||||||
|
panel pins and the last one when it lets go. */
|
||||||
|
.how__triggers {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 50svh;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
.how__trigger { height: var(--how-pitch); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Short screens: less air, smaller body, so the list always fits beside the panel. */
|
||||||
|
@media (min-width: 64rem) and (max-height: 52rem) {
|
||||||
|
.how { --how-inset: var(--space-5); }
|
||||||
|
.how__steps { margin-top: var(--space-5); }
|
||||||
|
.how__step { padding-block: var(--space-2); }
|
||||||
|
.how__step-inner p { font-size: var(--step-small); line-height: var(--leading-small); }
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.how__panel, .how__figure { transition: none; }
|
.how__step-body, .how__step-inner, .how__figure, .how__step-button { transition: none; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { initTabs } from '../../scripts/tabs';
|
/* The step being read is the one whose trigger crosses the middle of the viewport. Without
|
||||||
|
this the first step simply stays open, which is a readable section on its own. */
|
||||||
const root = document.querySelector<HTMLElement>('[data-how]');
|
const root = document.querySelector<HTMLElement>('[data-how]');
|
||||||
if (root) initTabs(root);
|
if (root) {
|
||||||
|
const steps = Array.from(root.querySelectorAll<HTMLElement>('[data-step]'));
|
||||||
|
const figures = Array.from(root.querySelectorAll<HTMLElement>('[data-figure]'));
|
||||||
|
const triggers = Array.from(root.querySelectorAll<HTMLElement>('[data-trigger]'));
|
||||||
|
const buttons = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-step-to]'));
|
||||||
|
|
||||||
|
const setActive = (index: number) => {
|
||||||
|
steps.forEach((step, i) => {
|
||||||
|
if (i === index) step.setAttribute('data-active', ''); else step.removeAttribute('data-active');
|
||||||
|
});
|
||||||
|
figures.forEach((figure, i) => {
|
||||||
|
if (i === index) figure.setAttribute('data-active', ''); else figure.removeAttribute('data-active');
|
||||||
|
});
|
||||||
|
buttons.forEach((button, i) => {
|
||||||
|
if (i === index) button.setAttribute('aria-current', 'step'); else button.removeAttribute('aria-current');
|
||||||
|
});
|
||||||
|
bodies.forEach((body, i) => {
|
||||||
|
body.style.height = i === index ? `${inners[i].offsetHeight}px` : '0px';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/* The box on the right takes the height of the longest step, so it holds still while the
|
||||||
|
text below each title grows and shrinks. */
|
||||||
|
const inners = Array.from(root.querySelectorAll<HTMLElement>('.how__step-inner'));
|
||||||
|
const bodies = Array.from(root.querySelectorAll<HTMLElement>('.how__step-body'));
|
||||||
|
const textInner = root.querySelector<HTMLElement>('.how__text-inner');
|
||||||
|
const activeIndex = () => steps.findIndex((step) => step.hasAttribute('data-active'));
|
||||||
|
const measure = () => {
|
||||||
|
if (!textInner) return;
|
||||||
|
/* Let it fall back to its natural height first, or it would measure its own floor. */
|
||||||
|
root.style.removeProperty('--how-text-h');
|
||||||
|
const open = inners[activeIndex()]?.offsetHeight ?? 0;
|
||||||
|
const base = textInner.offsetHeight - open;
|
||||||
|
const tallest = inners.reduce((max, el) => Math.max(max, el.offsetHeight), 0);
|
||||||
|
if (tallest > 0) root.style.setProperty('--how-text-h', `${Math.ceil(base + tallest)}px`);
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
let resizeTimer = 0;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
window.clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = window.setTimeout(measure, 150);
|
||||||
|
});
|
||||||
|
if (document.fonts?.ready) document.fonts.ready.then(measure).catch(() => {});
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isIntersecting) continue;
|
||||||
|
const index = triggers.indexOf(entry.target as HTMLElement);
|
||||||
|
if (index >= 0) setActive(index);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/* A line across the middle of the viewport: exactly one trigger spans it at a time. */
|
||||||
|
{ rootMargin: '-50% 0px -50% 0px' },
|
||||||
|
);
|
||||||
|
triggers.forEach((trigger) => observer.observe(trigger));
|
||||||
|
|
||||||
|
/* A step's title takes you to it, so the list works as a table of contents too. */
|
||||||
|
buttons.forEach((button, i) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const trigger = triggers[i];
|
||||||
|
if (!trigger) return;
|
||||||
|
const rect = trigger.getBoundingClientRect();
|
||||||
|
const top = window.scrollY + rect.top + rect.height / 2 - window.innerHeight / 2;
|
||||||
|
const smooth = window.matchMedia('(prefers-reduced-motion: no-preference)').matches;
|
||||||
|
window.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -3,8 +3,12 @@
|
|||||||
* A spec-sheet schematic rendered from a layout: nodes (main with dotted ring, or small
|
* A spec-sheet schematic rendered from a layout: nodes (main with dotted ring, or small
|
||||||
* satellites), edges (polylines, optional direction tick), and free text. One drawing per
|
* satellites), edges (polylines, optional direction tick), and free text. One drawing per
|
||||||
* variant; the caller decides which variant shows where.
|
* variant; the caller decides which variant shows where.
|
||||||
|
*
|
||||||
|
* Elements that belong to one node (its ring, satellites, branches and labels) render in one
|
||||||
|
* group, so a hover can single it out. The per-element delays (--d) are kept in the markup for
|
||||||
|
* a future draw-in; nothing animates by default.
|
||||||
*/
|
*/
|
||||||
import type { Layout } from './diagrams';
|
import type { Edge, Layout, Node, Text } from './diagrams';
|
||||||
interface Props { layout: Layout; title: string; desc: string; variant: 'wide' | 'tall'; id: string }
|
interface Props { layout: Layout; title: string; desc: string; variant: 'wide' | 'tall'; id: string }
|
||||||
const { layout, title, desc, variant, id } = Astro.props;
|
const { layout, title, desc, variant, id } = Astro.props;
|
||||||
const stroke = 1.25;
|
const stroke = 1.25;
|
||||||
@@ -12,48 +16,124 @@ const tick = (x: number, y: number, dx: number, dy: number, s = 7) => {
|
|||||||
const len = Math.hypot(dx, dy) || 1; const ux = dx / len, uy = dy / len; const px = -uy, py = ux;
|
const len = Math.hypot(dx, dy) || 1; const ux = dx / len, uy = dy / len; const px = -uy, py = ux;
|
||||||
return `${x - ux * s + px * s},${y - uy * s + py * s} ${x},${y} ${x - ux * s - px * s},${y - uy * s - py * s}`;
|
return `${x - ux * s + px * s},${y - uy * s + py * s} ${x},${y} ${x - ux * s - px * s},${y - uy * s - py * s}`;
|
||||||
};
|
};
|
||||||
/* A tick sits at the midpoint of the edge's last segment, pointing along it. */
|
|
||||||
const edgeTick = (pts: [number, number][]) => {
|
const edgeTick = (pts: [number, number][]) => {
|
||||||
const [a, b] = [pts[pts.length - 2], pts[pts.length - 1]];
|
const [a, b] = [pts[pts.length - 2], pts[pts.length - 1]];
|
||||||
return tick((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, b[0] - a[0], b[1] - a[1]);
|
return tick((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, b[0] - a[0], b[1] - a[1]);
|
||||||
};
|
};
|
||||||
const lines = (t: string | string[]) => (Array.isArray(t) ? t : [t]);
|
const lines = (t: string | string[]) => (Array.isArray(t) ? t : [t]);
|
||||||
|
const pts = (e: Edge) => e.points.map(([x, y]) => `${x},${y}`).join(' ');
|
||||||
|
|
||||||
|
/* A label ready to render: position, anchor, lines, delay. */
|
||||||
|
interface L { x: number; y: number; anchor: string; lines: string[]; d: number; strong?: boolean }
|
||||||
|
const nodeLabel = (n: Node, d: number): L | null => n.label ? {
|
||||||
|
x: n.x + (n.dx ?? (n.dot === 'sub' ? 12 : 0)),
|
||||||
|
y: n.y + (n.dy ?? (n.dot === 'sub' ? 5 : 6)),
|
||||||
|
anchor: n.anchor ?? 'start', lines: lines(n.label), d,
|
||||||
|
} : null;
|
||||||
|
const textLabel = (t: Text, d: number): L => ({ x: t.x, y: t.y, anchor: t.anchor ?? 'start', lines: lines(t.text), d, strong: t.strong });
|
||||||
|
const compact = (xs: (L | null)[]): L[] => xs.filter((x): x is L => x !== null);
|
||||||
|
|
||||||
|
/* Group order: the order in which a group first appears among nodes, edges and texts. */
|
||||||
|
const order: string[] = [];
|
||||||
|
const seen = (g?: string) => { if (g && !order.includes(g)) order.push(g); };
|
||||||
|
layout.nodes.forEach((n) => seen(n.group));
|
||||||
|
layout.edges.forEach((e) => seen(e.group));
|
||||||
|
layout.texts?.forEach((t) => seen(t.group));
|
||||||
|
|
||||||
|
const freeEdges = layout.edges.filter((e) => !e.group).map((e, i) => ({ e, points: pts(e), tick: e.tick ? edgeTick(e.points) : null, d: i * 150 }));
|
||||||
|
const groups = order.map((g, gi) => {
|
||||||
|
const start = 320 + gi * 200;
|
||||||
|
const nodes = layout.nodes.filter((n) => n.group === g);
|
||||||
|
const edges = layout.edges.filter((e) => e.group === g);
|
||||||
|
const texts = (layout.texts ?? []).filter((t) => t.group === g);
|
||||||
|
return {
|
||||||
|
key: g,
|
||||||
|
rings: nodes.filter((n) => n.ring).map((n) => ({ x: n.x, y: n.y, r: n.ring!, d: start })),
|
||||||
|
edges: edges.map((e, i) => ({ e, points: pts(e), tick: e.tick ? edgeTick(e.points) : null, d: start + 80 + i * 45 })),
|
||||||
|
dots: nodes.filter((n) => n.dot !== 'none').map((n, i) => ({ x: n.x, y: n.y, sub: n.dot === 'sub', d: start + 120 + i * 45 })),
|
||||||
|
mains: compact(nodes.filter((n) => n.dot !== 'sub').map((n) => nodeLabel(n, start + 260))),
|
||||||
|
subs: compact(nodes.filter((n) => n.dot === 'sub').map((n, i) => nodeLabel(n, start + 200 + i * 45))),
|
||||||
|
texts: texts.map((t) => textLabel(t, start + 320)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const freeStart = 320 + groups.length * 200;
|
||||||
|
const freeNodes = layout.nodes.filter((n) => !n.group);
|
||||||
|
const free = {
|
||||||
|
rings: freeNodes.filter((n) => n.ring).map((n, i) => ({ x: n.x, y: n.y, r: n.ring!, d: freeStart + i * 60 })),
|
||||||
|
dots: freeNodes.filter((n) => n.dot !== 'none').map((n, i) => ({ x: n.x, y: n.y, sub: n.dot === 'sub', d: freeStart + i * 60 })),
|
||||||
|
mains: compact(freeNodes.filter((n) => n.dot !== 'sub').map((n, i) => nodeLabel(n, freeStart + 100 + i * 60))),
|
||||||
|
subs: compact(freeNodes.filter((n) => n.dot === 'sub').map((n, i) => nodeLabel(n, freeStart + 120 + i * 40))),
|
||||||
|
texts: (layout.texts ?? []).filter((t) => !t.group).map((t, i) => textLabel(t, freeStart + 160 + i * 60)),
|
||||||
|
};
|
||||||
|
const fontMain = layout.fontMain ?? 17;
|
||||||
|
const fontSub = layout.fontSub ?? 15;
|
||||||
---
|
---
|
||||||
<svg class:list={['schematic', `schematic--${variant}`]} viewBox={`0 0 ${layout.w} ${layout.h}`} role="img" aria-labelledby={`${id}-title ${id}-desc`} xmlns="http://www.w3.org/2000/svg">
|
<svg class:list={['schematic', `schematic--${variant}`]} viewBox={`0 0 ${layout.w} ${layout.h}`} role="img" aria-labelledby={`${id}-title ${id}-desc`} xmlns="http://www.w3.org/2000/svg" font-size={fontSub}>
|
||||||
<title id={`${id}-title`}>{title}</title>
|
<title id={`${id}-title`}>{title}</title>
|
||||||
<desc id={`${id}-desc`}>{desc}</desc>
|
<desc id={`${id}-desc`}>{desc}</desc>
|
||||||
<g fill="none" stroke="currentColor" stroke-width={stroke} stroke-linecap="square" stroke-linejoin="round" vector-effect="non-scaling-stroke">
|
|
||||||
{layout.edges.map((e) => (
|
<g class="schematic__edges" fill="none" stroke="currentColor" stroke-width={stroke} vector-effect="non-scaling-stroke">
|
||||||
<g class:list={{ 'schematic__faint': e.faint }}>
|
{freeEdges.map((f) => (
|
||||||
<polyline points={e.points.map(([x, y]) => `${x},${y}`).join(' ')} stroke-dasharray={e.dashed ? '2 5' : undefined} />
|
<g class:list={{ 'schematic__faint': f.e.faint }}>
|
||||||
{e.tick && <polyline points={edgeTick(e.points)} />}
|
<polyline class="s-edge" pathLength="1" points={f.points} stroke-dasharray={f.e.dashed ? '2 5' : undefined} style={`--d: ${f.d}ms`} />
|
||||||
|
{f.tick && <polyline class="s-edge" pathLength="1" points={f.tick} style={`--d: ${f.d + 500}ms`} />}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{groups.map((g) => (
|
||||||
|
<g class="schematic__group" data-group={g.key}>
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width={stroke} vector-effect="non-scaling-stroke">
|
||||||
|
{g.rings.map((r) => <circle class="s-ring" cx={r.x} cy={r.y} r={r.r} stroke-dasharray="2 5" style={`--d: ${r.d}ms`} />)}
|
||||||
|
{g.edges.map((f) => (
|
||||||
|
<g class:list={{ 'schematic__faint': f.e.faint }}>
|
||||||
|
<polyline class="s-edge" pathLength="1" points={f.points} style={`--d: ${f.d}ms`} />
|
||||||
|
{f.tick && <polyline class="s-edge" pathLength="1" points={f.tick} style={`--d: ${f.d + 420}ms`} />}
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
{layout.nodes.filter((n) => n.ring).map((n) => <circle cx={n.x} cy={n.y} r={n.ring} stroke-dasharray="2 5" />)}
|
|
||||||
{layout.boxes?.map((b) => <rect x={b.x} y={b.y} width={b.w} height={b.h} stroke-dasharray={b.dashed ? '2 5' : undefined} />)}
|
|
||||||
</g>
|
</g>
|
||||||
<g fill="currentColor">
|
<g fill="currentColor">
|
||||||
{layout.nodes.map((n) => n.dot === 'none' ? null : <circle cx={n.x} cy={n.y} r={n.dot === 'sub' ? 3 : 5} class:list={{ 'schematic__dot--sub': n.dot === 'sub' }} />)}
|
{g.dots.map((d) => <circle class:list={['s-dot', { 'schematic__dot--sub': d.sub }]} cx={d.x} cy={d.y} r={d.sub ? 3 : 5} style={`--d: ${d.d}ms`} />)}
|
||||||
</g>
|
</g>
|
||||||
<g class="schematic__labels" font-size={layout.fontMain ?? 17} font-weight="500">
|
<g class="schematic__labels" font-size={fontMain} font-weight="500">
|
||||||
{layout.nodes.filter((n) => n.label && n.dot !== 'sub').map((n) => (
|
{g.mains.map((l) => (
|
||||||
<text x={n.x + (n.dx ?? 0)} y={n.y + (n.dy ?? 6)} text-anchor={n.anchor ?? 'start'}>
|
<text class="s-label" x={l.x} y={l.y} text-anchor={l.anchor} style={`--d: ${l.d}ms`}>
|
||||||
{lines(n.label!).map((l, i) => <tspan x={n.x + (n.dx ?? 0)} dy={i === 0 ? 0 : '1.25em'}>{l}</tspan>)}
|
{l.lines.map((s, i) => <tspan x={l.x} dy={i === 0 ? 0 : '1.25em'}>{s}</tspan>)}
|
||||||
</text>
|
</text>
|
||||||
))}
|
))}
|
||||||
</g>
|
</g>
|
||||||
<g class="schematic__meta" font-size={layout.fontSub ?? 15}>
|
<g class="schematic__meta">
|
||||||
{layout.nodes.filter((n) => n.label && n.dot === 'sub').map((n) => (
|
{[...g.subs, ...g.texts].map((l) => (
|
||||||
<text x={n.x + (n.dx ?? 12)} y={n.y + (n.dy ?? 5)} text-anchor={n.anchor ?? 'start'}>
|
<text class:list={['s-label', { 'schematic__strong': l.strong }]} x={l.x} y={l.y} text-anchor={l.anchor} style={`--d: ${l.d}ms`}>
|
||||||
{lines(n.label!).map((l, i) => <tspan x={n.x + (n.dx ?? 12)} dy={i === 0 ? 0 : '1.25em'}>{l}</tspan>)}
|
{l.lines.map((s, i) => <tspan x={l.x} dy={i === 0 ? 0 : '1.25em'}>{s}</tspan>)}
|
||||||
</text>
|
</text>
|
||||||
))}
|
))}
|
||||||
{layout.texts?.map((t) => (
|
</g>
|
||||||
<text x={t.x} y={t.y} text-anchor={t.anchor ?? 'start'} class:list={{ 'schematic__strong': t.strong }}>
|
</g>
|
||||||
{lines(t.text).map((l, i) => <tspan x={t.x} dy={i === 0 ? 0 : '1.25em'}>{l}</tspan>)}
|
))}
|
||||||
|
|
||||||
|
<g class="schematic__free">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width={stroke} vector-effect="non-scaling-stroke">
|
||||||
|
{free.rings.map((r) => <circle class="s-ring" cx={r.x} cy={r.y} r={r.r} stroke-dasharray="2 5" style={`--d: ${r.d}ms`} />)}
|
||||||
|
</g>
|
||||||
|
<g fill="currentColor">
|
||||||
|
{free.dots.map((d) => <circle class:list={['s-dot', { 'schematic__dot--sub': d.sub }]} cx={d.x} cy={d.y} r={d.sub ? 3 : 5} style={`--d: ${d.d}ms`} />)}
|
||||||
|
</g>
|
||||||
|
<g class="schematic__labels" font-size={fontMain} font-weight="500">
|
||||||
|
{free.mains.map((l) => (
|
||||||
|
<text class="s-label" x={l.x} y={l.y} text-anchor={l.anchor} style={`--d: ${l.d}ms`}>
|
||||||
|
{l.lines.map((s, i) => <tspan x={l.x} dy={i === 0 ? 0 : '1.25em'}>{s}</tspan>)}
|
||||||
</text>
|
</text>
|
||||||
))}
|
))}
|
||||||
</g>
|
</g>
|
||||||
|
<g class="schematic__meta">
|
||||||
|
{[...free.subs, ...free.texts].map((l) => (
|
||||||
|
<text class:list={['s-label', { 'schematic__strong': l.strong }]} x={l.x} y={l.y} text-anchor={l.anchor} style={`--d: ${l.d}ms`}>
|
||||||
|
{l.lines.map((s, i) => <tspan x={l.x} dy={i === 0 ? 0 : '1.25em'}>{s}</tspan>)}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -68,4 +148,14 @@ const lines = (t: string | string[]) => (Array.isArray(t) ? t : [t]);
|
|||||||
.schematic__strong { fill: var(--ink); font-weight: var(--weight-medium); }
|
.schematic__strong { fill: var(--ink); font-weight: var(--weight-medium); }
|
||||||
.schematic__faint { stroke: var(--line); }
|
.schematic__faint { stroke: var(--line); }
|
||||||
.schematic__dot--sub { fill: var(--ink-muted); }
|
.schematic__dot--sub { fill: var(--ink-muted); }
|
||||||
|
.s-ring { pointer-events: all; }
|
||||||
|
.s-dot { transform-box: fill-box; transform-origin: center; }
|
||||||
|
|
||||||
|
/* Hover singles out one node and what belongs to it. */
|
||||||
|
@media (hover: hover) and (min-width: 64rem) {
|
||||||
|
.schematic__group, .schematic__edges, .schematic__free { transition: opacity var(--dur) var(--ease); }
|
||||||
|
.schematic:has(.schematic__group:hover) .schematic__group:not(:hover) { opacity: 0.28; }
|
||||||
|
.schematic:has(.schematic__group:hover) .schematic__edges,
|
||||||
|
.schematic:has(.schematic__group:hover) .schematic__free { opacity: 0.45; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -9,10 +9,10 @@ type Groups = HowGroups;
|
|||||||
type Pt = [number, number];
|
type Pt = [number, number];
|
||||||
type Label = string | string[];
|
type Label = string | string[];
|
||||||
|
|
||||||
export interface Node { x: number; y: number; label?: Label; dot?: 'main' | 'sub' | 'none'; ring?: number; anchor?: 'start' | 'end' | 'middle'; dx?: number; dy?: number }
|
export interface Node { x: number; y: number; label?: Label; dot?: 'main' | 'sub' | 'none'; ring?: number; anchor?: 'start' | 'end' | 'middle'; dx?: number; dy?: number; group?: string }
|
||||||
export interface Edge { points: Pt[]; tick?: boolean; faint?: boolean; dashed?: boolean }
|
export interface Edge { points: Pt[]; tick?: boolean; faint?: boolean; dashed?: boolean; group?: string }
|
||||||
export interface Box { x: number; y: number; w: number; h: number; dashed?: boolean }
|
export interface Box { x: number; y: number; w: number; h: number; dashed?: boolean }
|
||||||
export interface Text { x: number; y: number; text: Label; anchor?: 'start' | 'end' | 'middle'; strong?: boolean }
|
export interface Text { x: number; y: number; text: Label; anchor?: 'start' | 'end' | 'middle'; strong?: boolean; group?: string }
|
||||||
export interface Layout { w: number; h: number; nodes: Node[]; edges: Edge[]; boxes?: Box[]; texts?: Text[]; fontMain?: number; fontSub?: number }
|
export interface Layout { w: number; h: number; nodes: Node[]; edges: Edge[]; boxes?: Box[]; texts?: Text[]; fontMain?: number; fontSub?: number }
|
||||||
export interface Pair { wide: Layout; tall: Layout }
|
export interface Pair { wide: Layout; tall: Layout }
|
||||||
|
|
||||||
@@ -20,20 +20,20 @@ const RING = 36;
|
|||||||
const LINE = 21; // one line of a 17px label, in viewBox units
|
const LINE = 21; // one line of a 17px label, in viewBox units
|
||||||
|
|
||||||
/* A ringed main node with its label centred below it and an optional muted line under that. */
|
/* A ringed main node with its label centred below it and an optional muted line under that. */
|
||||||
function main(x: number, y: number, label: Label, sub?: Label, ring = RING): { nodes: Node[]; texts: Text[] } {
|
function main(x: number, y: number, label: Label, sub?: Label, ring = RING, group?: string): { nodes: Node[]; texts: Text[] } {
|
||||||
const lines = Array.isArray(label) ? label.length : 1;
|
const lines = Array.isArray(label) ? label.length : 1;
|
||||||
const nodes: Node[] = [{ x, y, label, dot: 'main', ring, anchor: 'middle', dx: 0, dy: ring + 22 }];
|
const nodes: Node[] = [{ x, y, label, dot: 'main', ring, anchor: 'middle', dx: 0, dy: ring + 22, group }];
|
||||||
const texts: Text[] = sub ? [{ x, y: y + ring + 22 + lines * LINE + 2, text: sub, anchor: 'middle' }] : [];
|
const texts: Text[] = sub ? [{ x, y: y + ring + 22 + lines * LINE + 2, text: sub, anchor: 'middle', group }] : [];
|
||||||
return { nodes, texts };
|
return { nodes, texts };
|
||||||
}
|
}
|
||||||
/* A plain dot with its label centred below. */
|
/* A plain dot with its label centred below. */
|
||||||
function dot(x: number, y: number, label: Label): Node {
|
function dot(x: number, y: number, label: Label, group?: string): Node {
|
||||||
return { x, y, label, dot: 'main', anchor: 'middle', dx: 0, dy: 28 };
|
return { x, y, label, dot: 'main', anchor: 'middle', dx: 0, dy: 28, group };
|
||||||
}
|
}
|
||||||
/* Satellites fanning from a node to a column of dots. */
|
/* Satellites fanning from a node to a column of dots. */
|
||||||
function fan(from: Pt, xs: number, ys: number[], labels: string[], anchor: 'start' | 'end'): { nodes: Node[]; edges: Edge[] } {
|
function fan(from: Pt, xs: number, ys: number[], labels: string[], anchor: 'start' | 'end', group?: string): { nodes: Node[]; edges: Edge[] } {
|
||||||
const nodes: Node[] = ys.map((y, i) => ({ x: xs, y, label: labels[i], dot: 'sub', anchor, dx: anchor === 'end' ? -12 : 12 }));
|
const nodes: Node[] = ys.map((y, i) => ({ x: xs, y, label: labels[i], dot: 'sub', anchor, dx: anchor === 'end' ? -12 : 12, group }));
|
||||||
const edges: Edge[] = ys.map((y) => ({ points: [from, [xs, y]], faint: true }));
|
const edges: Edge[] = ys.map((y) => ({ points: [from, [xs, y]], faint: true, group }));
|
||||||
return { nodes, edges };
|
return { nodes, edges };
|
||||||
}
|
}
|
||||||
const chain = (pts: Pt[], tick = true): Edge[] => pts.slice(1).map((p, i) => ({ points: [pts[i], p], tick }));
|
const chain = (pts: Pt[], tick = true): Edge[] => pts.slice(1).map((p, i) => ({ points: [pts[i], p], tick }));
|
||||||
@@ -67,12 +67,12 @@ function spine(groups: { title: Label; items?: string[]; note?: string }[]): Lay
|
|||||||
|
|
||||||
export function overview(g: Groups[0]): Pair {
|
export function overview(g: Groups[0]): Pair {
|
||||||
const L = g.labels;
|
const L = g.labels;
|
||||||
const y1 = 110, y2 = 330, ym = 220, fork = 190, join = 1050;
|
const y1 = 90, y2 = 300, ym = 195, fork = 190, join = 1050;
|
||||||
const sbom = main(340, y1, L.sbom, L.sbomSub), dt = main(640, y1, L.dt, L.dtSub);
|
const sbom = main(340, y1, L.sbom, L.sbomSub, RING, 'sbom'), dt = main(640, y1, L.dt, L.dtSub, RING, 'dt');
|
||||||
const sast = main(340, y2, L.sast, L.sastSub), poly = main(640, y2, L.poly, L.polySub), prism = main(900, y2, L.prism, L.prismSub);
|
const sast = main(340, y2, L.sast, L.sastSub, RING, 'sast'), poly = main(640, y2, L.poly, L.polySub, RING, 'poly'), prism = main(900, y2, L.prism, L.prismSub, RING, 'prism');
|
||||||
const wide: Layout = {
|
const wide: Layout = {
|
||||||
w: 1200, h: 470,
|
w: 1200, h: 430,
|
||||||
nodes: [dot(80, ym, L.repo), ...sbom.nodes, ...dt.nodes, ...sast.nodes, ...poly.nodes, ...prism.nodes, dot(1120, ym, L.report)],
|
nodes: [dot(80, ym, L.repo, 'repo'), ...sbom.nodes, ...dt.nodes, ...sast.nodes, ...poly.nodes, ...prism.nodes, dot(1120, ym, L.report, 'report')],
|
||||||
edges: [
|
edges: [
|
||||||
{ points: [[80, ym], [fork, ym], [fork, y1], [340, y1]], tick: true },
|
{ points: [[80, ym], [fork, ym], [fork, y1], [340, y1]], tick: true },
|
||||||
{ points: [[fork, ym], [fork, y2], [340, y2]], tick: true },
|
{ points: [[fork, ym], [fork, y2], [340, y2]], tick: true },
|
||||||
@@ -94,14 +94,15 @@ export function overview(g: Groups[0]): Pair {
|
|||||||
|
|
||||||
export function scanners(g: Groups[1]): Pair {
|
export function scanners(g: Groups[1]): Pair {
|
||||||
const L = g.labels;
|
const L = g.labels;
|
||||||
const focal: Pt = [500, 280];
|
const cy = 232;
|
||||||
const ys = g.tools.map((_, i) => 60 + i * 27.5);
|
const focal: Pt = [500, cy];
|
||||||
const tools = fan(focal, 210, ys, g.tools, 'end');
|
const ys = g.tools.map((_, i) => 40 + i * 24);
|
||||||
const one = main(500, 280, L.one, L.oneSub), cwe = main(700, 280, L.cwe, L.cweSub), merge = main(900, 280, L.merge, L.mergeSub), resolve = main(1090, 280, L.resolve, L.resolveSub);
|
const tools = fan(focal, 210, ys, g.tools, 'end', 'tools');
|
||||||
|
const one = main(500, cy, L.one, L.oneSub, RING, 'one'), cwe = main(700, cy, L.cwe, L.cweSub, RING, 'cwe'), merge = main(900, cy, L.merge, L.mergeSub, RING, 'merge'), resolve = main(1090, cy, L.resolve, L.resolveSub, RING, 'resolve');
|
||||||
const wide: Layout = {
|
const wide: Layout = {
|
||||||
w: 1200, h: 560,
|
w: 1200, h: 450,
|
||||||
nodes: [...tools.nodes, ...one.nodes, ...cwe.nodes, ...merge.nodes, ...resolve.nodes],
|
nodes: [...tools.nodes, ...one.nodes, ...cwe.nodes, ...merge.nodes, ...resolve.nodes],
|
||||||
edges: [...tools.edges, ...chain([[500, 280], [700, 280], [900, 280], [1090, 280]])],
|
edges: [...tools.edges, ...chain([[500, cy], [700, cy], [900, cy], [1090, cy]])],
|
||||||
texts: [...one.texts, ...cwe.texts, ...merge.texts, ...resolve.texts],
|
texts: [...one.texts, ...cwe.texts, ...merge.texts, ...resolve.texts],
|
||||||
};
|
};
|
||||||
const tall = spine([
|
const tall = spine([
|
||||||
@@ -113,14 +114,14 @@ export function scanners(g: Groups[1]): Pair {
|
|||||||
|
|
||||||
export function verdict(g: Groups[2]): Pair {
|
export function verdict(g: Groups[2]): Pair {
|
||||||
const L = g.labels;
|
const L = g.labels;
|
||||||
const y = 300, top = 120;
|
const y = 260, top = 80;
|
||||||
const ctx = fan([380, y], 230, [100, 140, 180, 220], L.ctx, 'end');
|
const ctx = fan([380, y], 230, [60, 100, 140, 180], L.ctx, 'end', 'context');
|
||||||
const tools = fan([920, y], 1040, [150, 190, 230, 270], L.tools, 'start');
|
const tools = fan([920, y], 1040, [110, 150, 190, 230], L.tools, 'start', 'escalation');
|
||||||
const outcomes = fan([1220, y], 1330, [240, 280, 320], L.outcomes, 'start');
|
const outcomes = fan([1220, y], 1330, [200, 240, 280], L.outcomes, 'start', 'verdict');
|
||||||
const context = main(380, y, L.context), s1 = main(640, y, L.stage1), s2 = main(920, y, L.stage2), v = main(1220, y, L.verdict, L.carries);
|
const context = main(380, y, L.context, undefined, RING, 'context'), s1 = main(640, y, L.stage1, undefined, RING, 'static'), s2 = main(920, y, L.stage2, undefined, RING, 'escalation'), v = main(1220, y, L.verdict, L.carries, RING, 'verdict');
|
||||||
const wide: Layout = {
|
const wide: Layout = {
|
||||||
w: 1460, h: 420,
|
w: 1460, h: 380,
|
||||||
nodes: [dot(140, y, L.finding), ...context.nodes, ...ctx.nodes, ...s1.nodes, ...s2.nodes, ...tools.nodes, ...v.nodes, ...outcomes.nodes],
|
nodes: [dot(140, y, L.finding, 'finding'), ...context.nodes, ...ctx.nodes, ...s1.nodes, ...s2.nodes, ...tools.nodes, ...v.nodes, ...outcomes.nodes],
|
||||||
edges: [
|
edges: [
|
||||||
...ctx.edges, ...tools.edges, ...outcomes.edges,
|
...ctx.edges, ...tools.edges, ...outcomes.edges,
|
||||||
...chain([[140, y], [380, y], [640, y], [920, y], [1220, y]]),
|
...chain([[140, y], [380, y], [640, y], [920, y], [1220, y]]),
|
||||||
@@ -141,14 +142,14 @@ export function verdict(g: Groups[2]): Pair {
|
|||||||
|
|
||||||
export function dependencies(g: Groups[3]): Pair {
|
export function dependencies(g: Groups[3]): Pair {
|
||||||
const L = g.labels;
|
const L = g.labels;
|
||||||
const y = 220, ya = 130, yb = 310, mergeX = 300;
|
const y = 170, ya = 80, yb = 260, mergeX = 300;
|
||||||
const sbom = main(430, y, L.sbom, L.sbomSub), dt = main(700, y, L.dt, L.dtSub), vdr = main(960, y, L.vdr, L.vdrSub);
|
const sbom = main(430, y, L.sbom, L.sbomSub, RING, 'sbom'), dt = main(700, y, L.dt, L.dtSub, RING, 'dt'), vdr = main(960, y, L.vdr, L.vdrSub, RING, 'vdr');
|
||||||
const wide: Layout = {
|
const wide: Layout = {
|
||||||
w: 1200, h: 400,
|
w: 1200, h: 320,
|
||||||
nodes: [
|
nodes: [
|
||||||
{ x: 150, y: ya, label: L.syft, dot: 'main', anchor: 'end', dx: -16, dy: 6 },
|
{ x: 150, y: ya, label: L.syft, dot: 'main', anchor: 'end', dx: -16, dy: 6, group: 'generators' },
|
||||||
{ x: 150, y: yb, label: L.cdxgen, dot: 'main', anchor: 'end', dx: -16, dy: 6 },
|
{ x: 150, y: yb, label: L.cdxgen, dot: 'main', anchor: 'end', dx: -16, dy: 6, group: 'generators' },
|
||||||
...sbom.nodes, ...dt.nodes, ...vdr.nodes, dot(1130, y, L.report),
|
...sbom.nodes, ...dt.nodes, ...vdr.nodes, dot(1130, y, L.report, 'report'),
|
||||||
],
|
],
|
||||||
edges: [
|
edges: [
|
||||||
{ points: [[150, ya], [mergeX, ya], [mergeX, y]] },
|
{ points: [[150, ya], [mergeX, ya], [mergeX, y]] },
|
||||||
@@ -168,23 +169,24 @@ export function dependencies(g: Groups[3]): Pair {
|
|||||||
|
|
||||||
export function operate(g: Groups[4]): Pair {
|
export function operate(g: Groups[4]): Pair {
|
||||||
const L = g.labels;
|
const L = g.labels;
|
||||||
const hub: Pt = [560, 220];
|
const hub: Pt = [560, 200];
|
||||||
const ys = [100, 160, 220, 280, 340];
|
const ys = [100, 150, 200, 250, 300];
|
||||||
const sources = fan(hub, 200, ys, L.sources, 'end');
|
const sources = fan(hub, 200, ys, L.sources, 'end', 'sources');
|
||||||
const outputs = fan(hub, 920, ys, L.outputs, 'start');
|
const outputs = fan(hub, 920, ys, L.outputs, 'start', 'outputs');
|
||||||
|
const sy = 410;
|
||||||
const wide: Layout = {
|
const wide: Layout = {
|
||||||
w: 1200, h: 540,
|
w: 1200, h: 470,
|
||||||
nodes: [
|
nodes: [
|
||||||
{ x: hub[0], y: hub[1], label: L.hub, dot: 'main', ring: 44, anchor: 'middle', dx: 0, dy: -58 },
|
{ x: hub[0], y: hub[1], label: L.hub, dot: 'main', ring: 44, anchor: 'middle', dx: 0, dy: -58, group: 'hub' },
|
||||||
...sources.nodes, ...outputs.nodes,
|
...sources.nodes, ...outputs.nodes,
|
||||||
dot(420, 460, L.scopes[0]), dot(560, 460, L.scopes[1]), dot(700, 460, L.scopes[2]),
|
dot(420, sy, L.scopes[0], 'scopes'), dot(560, sy, L.scopes[1], 'scopes'), dot(700, sy, L.scopes[2], 'scopes'),
|
||||||
],
|
],
|
||||||
edges: [
|
edges: [
|
||||||
...sources.edges, ...outputs.edges,
|
...sources.edges, ...outputs.edges,
|
||||||
{ points: [[560, 264], [560, 460]], faint: true },
|
{ points: [[560, 244], [560, sy]], faint: true, group: 'scopes' },
|
||||||
...chain([[420, 460], [560, 460], [700, 460]]),
|
...chain([[420, sy], [560, sy], [700, sy]]).map((e) => ({ ...e, group: 'scopes' })),
|
||||||
],
|
],
|
||||||
texts: [{ x: 540, y: 425, text: L.scopesTitle, anchor: 'end' }],
|
texts: [{ x: 540, y: sy - 35, text: L.scopesTitle, anchor: 'end', group: 'scopes' }],
|
||||||
};
|
};
|
||||||
const tall = spine([
|
const tall = spine([
|
||||||
{ title: L.hub, items: L.sources },
|
{ title: L.hub, items: L.sources },
|
||||||
|
|||||||
+4
-1
@@ -7,6 +7,9 @@
|
|||||||
"exclude": [
|
"exclude": [
|
||||||
"dist",
|
"dist",
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"product_repo"
|
"product_repo",
|
||||||
|
"ds-bundle",
|
||||||
|
".ds-sync",
|
||||||
|
".design-sync"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user