This commit is contained in:
Felix Klement committed 2026-09-20 12:45:33 +02:00
1 parent b244287f6a
commit 5a2d49c17f
77 files changed
+2287 -29

No files matched your search

Vendored
BIN
View File
Binary file not shown.
+111
View File
@@ -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.
+25
View File
@@ -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"
}
}
}
+79
View File
@@ -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.
+21
View File
@@ -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">`.
+26
View File
@@ -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>
);
}
+23
View File
@@ -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.
+14
View File
@@ -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>;
}
+19
View File
@@ -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">`.
+10
View File
@@ -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>;
}
+22
View File
@@ -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">`.
+12
View File
@@ -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>;
}
+17
View File
@@ -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">`.
+10
View File
@@ -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>;
}
+21
View File
@@ -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">`.
+10
View File
@@ -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>;
}
+19
View File
@@ -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">`.
+18
View File
@@ -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>
);
}
+22
View File
@@ -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">`.
+10
View File
@@ -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>;
}
+17
View File
@@ -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">`.
+9
View File
@@ -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" />;
}
+24
View File
@@ -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">`.
+28
View File
@@ -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>
);
}
+26
View File
@@ -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);">`.
+18
View File
@@ -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>
);
}
+24
View File
@@ -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`.
+26
View File
@@ -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>
);
}
+20
View File
@@ -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">`.
+19
View File
@@ -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>
);
}
+17
View File
@@ -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">`.
+15
View File
@@ -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>
);
}
+5
View File
@@ -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(' ');
+28
View File
@@ -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';
+14
View File
@@ -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"]
}
+301
View File
@@ -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;
}
}
+9
View File
@@ -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;
+10
View File
@@ -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;
+6
View File
@@ -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;
+8
View File
@@ -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;
+6
View File
@@ -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;
+6
View File
@@ -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;
+9
View File
@@ -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;
+6
View File
@@ -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;
+6
View File
@@ -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;
+11
View File
@@ -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;
+10
View File
@@ -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;
+8
View File
@@ -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;
+12
View File
@@ -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;
+7
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
export declare const cx: (...parts: Array<string | false | null | undefined>) => string;
+28
View File
@@ -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';
+39
View File
@@ -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)`);
+165
View File
@@ -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.');
}
+52
View File
@@ -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>
);
+37
View File
@@ -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>
);
+17
View File
@@ -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>
);
+26
View File
@@ -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>
);
+17
View File
@@ -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>
);
+53
View File
@@ -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>
);
+35
View File
@@ -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>
);
+23
View File
@@ -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>
);
+26
View File
@@ -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>
);
+57
View File
@@ -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>
);
+45
View File
@@ -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>
);
+32
View File
@@ -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>
);
+41
View File
@@ -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>
);
+26
View File
@@ -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
View File
@@ -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
+12 -2
View File
@@ -23,11 +23,10 @@ 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 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. | | `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 (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/*` | 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`. Every element carries the `group` of the node it belongs to, which drives the hover focus and the draw-in order. | | `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. |
| `src/scripts/reveal.ts` | Marks `[data-animate]` drawings as drawn once they scroll into view. The drawings' own CSS turns that into the plotter-style reveal, wide screens only, off under `prefers-reduced-motion`. |
| `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. |
@@ -53,6 +52,17 @@ 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 hero video
The home hero plays `public/video/hero-plant.mp4` / `.webm` behind the headline, over the The home hero plays `public/video/hero-plant.mp4` / `.webm` behind the headline, over the
+269
View File
@@ -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.
+1
View File
@@ -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",
+14
View File
@@ -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();
+52 -25
View File
@@ -55,7 +55,10 @@ function plane(n: number, u: number, y: number, cy: number, cx = ISO.cx): string
return out; return out;
} }
const lines: string[] = []; /* 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;
@@ -70,15 +73,15 @@ if (kind === 'sphere') {
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);
track(ISO.cx + R, ISO.cy + R); track(ISO.cx + R, ISO.cy + R);
} }
@@ -87,12 +90,14 @@ 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;
lines.push(poly([iso(t, L, 0, cy), iso(t, L, L, cy)])); /* The first and last line of each face are the cube's own edges. */
lines.push(poly([iso(0, L, t, cy), iso(L, L, t, cy)])); const target = i === 0 || i === n ? accent : base;
lines.push(poly([iso(t, 0, 0, cy), iso(t, L, 0, cy)])); target.push(poly([iso(t, L, 0, cy), iso(t, L, L, cy)]));
lines.push(poly([iso(0, t, 0, cy), iso(L, t, 0, cy)])); target.push(poly([iso(0, L, t, cy), iso(L, L, t, cy)]));
lines.push(poly([iso(0, 0, t, cy), iso(0, L, t, cy)])); target.push(poly([iso(t, 0, 0, cy), iso(t, L, 0, cy)]));
lines.push(poly([iso(0, t, 0, cy), iso(0, t, L, cy)])); target.push(poly([iso(0, t, 0, cy), iso(L, t, 0, cy)]));
target.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)]));
} }
} }
@@ -100,28 +105,30 @@ 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));
} }
} }
@@ -131,29 +138,33 @@ if (kind === 'steps') {
const total = heights.length * w + (heights.length - 1) * gap; const total = heights.length * w + (heights.length - 1) * gap;
heights.forEach((h, i) => { heights.forEach((h, i) => {
const x0 = -total / 2 + i * (w + gap); const x0 = -total / 2 + i * (w + gap);
cuboid(x0, 0, -depth / 2, w, h, depth, cy).forEach((l) => lines.push(l)); /* 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; const L = total + 48;
lines.push(poly([iso(-L / 2, 0, -depth / 2 - 24, cy), iso(L / 2, 0, -depth / 2 - 24, cy)])); base.push(poly([iso(-L / 2, 0, -depth / 2 - 24, cy), iso(L / 2, 0, -depth / 2 - 24, cy)]));
} }
if (kind === 'nested') { if (kind === 'nested') {
/* A structure inside its own walls: a cube inside a cube, corners tied together. */ /* A structure inside its own walls: a cube inside a cube, corners tied together. */
const O = 200, I = 96, cy = ISO.cy + 84; const O = 200, I = 96, cy = ISO.cy + 84;
cuboid(-O / 2, 0, -O / 2, O, O, O, cy).forEach((l) => lines.push(l)); cuboid(-O / 2, 0, -O / 2, O, O, O, cy).forEach((l) => base.push(l));
const off = (O - I) / 2; const off = (O - I) / 2;
cuboid(-I / 2, off, -I / 2, I, I, I, cy).forEach((l) => lines.push(l)); 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 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]]; 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) => lines.push(poly([iso(x, y, z, cy), iso(inner[k][0], inner[k][1], inner[k][2], cy)]))); 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') { if (kind === 'stack') {
/* Layers one on top of the other, each a record kept whole. */ /* 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; const n = 4, u = 38, L = n * u, gapY = 46, layers = 4, cy = ISO.cy + 60;
for (let k = 0; k < layers; k++) plane(n, u, k * gapY, cy).forEach((l) => lines.push(l)); /* 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]]; const corners: [number, number][] = [[0, 0], [L, 0], [L, L], [0, L]];
corners.forEach(([x, z]) => lines.push(poly([iso(x, 0, z, cy), iso(x, (layers - 1) * gapY, z, cy)]))); 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 /* 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 keeps the same footprint, so the four cards of a row stay even. The padding covers the
@@ -165,11 +176,18 @@ 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(' '); const viewBox = [minX + width / 2 - side / 2, minY + height / 2 - side / 2, side, side].map(f).join(' ');
--- ---
<svg class:list={['grid-object', className]} viewBox={viewBox} 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>
@@ -177,5 +195,14 @@ const viewBox = [minX + width / 2 - side / 2, minY + height / 2 - side / 2, side
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>
+7
View File
@@ -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)); }
+7
View File
@@ -41,6 +41,13 @@ const objects = ['steps', 'nested', 'stack'] as const;
hyphens: auto; hyphens: auto;
} }
.vcards__item :global(.vcards__object) { width: 100%; max-width: 14rem; justify-self: center; align-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)); }
+4 -1
View File
@@ -7,6 +7,9 @@
"exclude": [ "exclude": [
"dist", "dist",
"node_modules", "node_modules",
"product_repo" "product_repo",
"ds-bundle",
".ds-sync",
".design-sync"
] ]
} }