DESIGN.md
Rama master · réplica de lectura del repositorio original.
# VeriWorkly Design System
The complete specification for how VeriWorkly looks, reads, and behaves — across the
marketing site, the studio app, the docs and blog platforms, portfolio templates, and
every share card and brand asset we publish.
## How to use this document
| If you want to… | Go to |
| :---------------------------------- | :------------------------------------------ |
| Copy a colour value | [Colour](#colour) |
| Set a heading correctly | [Typography](#typography) |
| Use the logo | [Logo](#logo) |
| Build a page layout | [Spacing & layout](#spacing--layout) |
| Pick a component | [Component library](#component-library) |
| Animate something | [Motion](#motion) |
| Write UI or marketing copy | [Voice](#voice) |
| Change a token | [Changing the system](#changing-the-system) |
| Know what is currently inconsistent | [Known deviations](#known-deviations) |
### Sources of truth
Three files define the system. Everything else consumes them.
| File | Owns |
| :----------------------------------- | :-------------------------------------------------------------------------------------------------------- |
| `packages/ui/src/styles/themes.css` | Every colour token, in both themes. What the product actually renders. |
| `packages/ui/src/styles/globals.css` | Base element styles, `.surface-grid`, the page gradient, reduced-motion. |
| `apps/site/config/brand.ts` | The published description of the system — palette, type scale, logo manifest, motion tokens, press facts. |
`apps/site/config/brand.ts` is what `/style-guide`, `/brand-kit`, and the downloadable
`.zip` all render from. It mirrors `themes.css` by hand, because CSS custom properties
cannot be imported into a React tree or a Node build script. **Change the two together
in the same commit.** A token that disagrees between them is a published lie.
This document is the third description of the same palette, so it can drift too. It does
not have to be taken on trust:
```bash
cd apps/site && npm run check:design
```
compares all three, token by token, and fails if any of them disagree.
Live, interactive versions of everything below:
- `https://veriworkly.com/style-guide` — the full system, with working components
- `https://veriworkly.com/brand-kit` — the outward-facing press kit
---
## Brand foundation
**Product.** VeriWorkly is a free-to-use, open-core, privacy-first AI career workspace:
resumes, cover letters, and web portfolios, with no login required to start.
**Core metaphor.** The Master Profile is one verified record of a career. Every document
is a view of it — never re-typed, never out of sync.
**Essence line.** _One profile. Every document._
**Design stance.** Warm ivory rather than stark white. One blue accent doing all the
work. Generous negative space. Type carries the page; ornament does not. Motion confirms
an action and then gets out of the way.
**Name.** `VeriWorkly` — one word, capital V and W. Never _Veriworkly_, _veriworkly_,
_Veri Workly_, or _VeriWorkly.ai_.
**Licence.** MIT. Copyright © 2026 Gautam Raj.
---
## Logo
The mark is a faceted **W** with a period below its centre vertex — a wordmark
abbreviation reduced to a single glyph. There is no separate wordmark lockup; when a text
label is needed, set "VeriWorkly" in Geist Sans Semibold beside the mark.
### Geometry
Authored on a `512 × 512` canvas, exactly symmetric about `x = 256`.
| Property | Value |
| :----------- | :-------------------------------------------------------------------------------------------------- |
| Canvas | `viewBox="0 0 512 512"` |
| Mark bounds | `x` 66 → 446, `y` 117 → 395 |
| Occupies | 74% of canvas width, 54% of height |
| Silhouette | One closed path, 13 vertices |
| Period | Circle at `(256, 381.8)`, `r = 12.3` |
| Construction | Facet polygons are clipped to the silhouette, so the outline stays crisp independent of facet edits |
The silhouette path, for reference:
```
M66 117H156l57.2 137.6L236.1 200h39.8l22.9 54.6L356 117h90L322 395h-35.6L256 324l-30.4 71H190Z
```
### Facet palette
Five planes, lightest to darkest. These are the mark's own colours and are **not** theme
tokens — the logo does not recolour with the theme.
| Plane | Value |
| :--------------- | :-------------------- |
| Highlight | `#58C2F0` |
| Centre peak | `#2DAFEB` |
| Base | `#0299DA` |
| Outer descending | `#0D6EB5` |
| Core / period | `#055390` / `#065898` |
### Variants
| File | Use |
| :-------------------------------- | :--------------------------------------------------------- |
| `veriworkly-logo-256.png` | Primary mark. For websites, headers, and media placements. |
| `veriworkly-icon-512.png` | Store listings, app launchers, and high-DPI displays. |
| `veriworkly-icon-192.png` | Android home screen, manifest, and favicons. |
| `veriworkly-icon-apple-touch.png` | iOS home screen and web clip. |
All live in `apps/site/public/brand/logo/`. The manifest is `logoAssets` in
`apps/site/config/brand.ts`; the `.zip` is built from that list, so adding a file there
and re-running the build is the whole process.
### Usage rules
| Rule | Value |
| :------------------- | :------------------------------------- |
| Clear space | 25% of the mark's width, on every side |
| Minimum display size | 16 px |
Nothing enters the clear-space box — not type, rules, other logos, or the edge of a
photograph.
**Never** recolour the mark, distort its proportions, rotate it, add shadows, glows,
gradients, or outlines, or place it on a ground that breaks its contrast.
---
## Colour
Every token carries two values. The light column applies on pale grounds, the dark column
under `.dark`. Values below are authoritative and match
`packages/ui/src/styles/themes.css`.
### Core palette
| Token | Light | Dark | Role |
| :-------------------- | :----------------------- | :-------------------------- | :--------------------------------------------------------- |
| `--background` | `#F5F4EF` | `#0D1117` | Page ground. Warm ivory, not white. |
| `--foreground` | `#171717` | `#F3F4F6` | Primary text. |
| `--card` | `#FFFFFF` | `#121924` | Raised surfaces, one step off the page. |
| `--muted` | `#5F5C54` | `#94A3B8` | Secondary text, metadata, captions. |
| `--border` | `rgba(23, 23, 23, 0.12)` | `rgba(148, 163, 184, 0.25)` | Dividers and outlines. Always alpha, never a solid grey. |
| `--accent` | `#2563EB` | `#60A5FA` | Links, CTAs, focus rings. Lifted in dark to hold contrast. |
| `--accent-foreground` | `#F8FBFF` | `#0F172A` | Text and icons on an accent fill. |
### Status palette
Semantic status is deliberately separate from `--accent`: accent means "this is
VeriWorkly", status means "this is the state of your data". There are four states and four
tokens. Informational states reuse `--accent` rather than introducing a fifth hue.
| Token | Light | Dark | Role |
| :------------------------- | :-------- | :-------- | :------------------------------------------ |
| `--destructive` | `#B91C1C` | `#EF4444` | Errors, destructive actions, failed states. |
| `--destructive-foreground` | `#FFFFFF` | `#FFFFFF` | Text on a destructive fill. |
| `--success` | `#047857` | `#34D399` | Completed, valid, passing. |
| `--success-foreground` | `#FFFFFF` | `#052E1F` | Text on a success fill. |
| `--warning` | `#B45309` | `#FBBF24` | Needs attention but has not failed. |
| `--warning-foreground` | `#FFFFFF` | `#271A02` | Text on a warning fill. |
### Internal tokens
| Token | Light | Dark | Role |
| :------------------- | :------------------------ | :----------------------- | :-------------------------------------------------------- |
| `--muted-foreground` | `#171717` | `#171717` | Text on a muted fill. Identical in both themes by design. |
| `--fd-accent` | `rgba(96, 165, 250, 0.2)` | `rgba(130, 139, 2, 0.8)` | Fumadocs search/menu highlight. Docs platform only. |
### Tailwind mapping
Tokens become utilities through an `@theme inline` block in each app's `globals.css`:
```css
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-muted: var(--muted);
--color-border: var(--border);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
}
```
Mapped in `apps/site`, `apps/studio`, `apps/blog-platform`, and `apps/docs-platform`.
`apps/portfolio` runs a parallel system — see [Portfolio](#portfolio).
**A token that is not mapped produces no utility.** `apps/studio` and `apps/blog-platform`
map `--color-muted-foreground` to `--muted`; `apps/site` and `apps/docs-platform` do not,
so `text-muted-foreground` is inert there. Shared components therefore use `text-muted`,
never `text-muted-foreground`. If you add a token to `themes.css` and intend to use it as
a class, map it in all four apps.
**`color-scheme`** is declared in `themes.css` (`light` on `:root`, `dark` under `.dark`),
so native scrollbars, `<select>` pickers, date inputs, and autofill follow the theme.
### Conventions
- **Tinted fills.** A status or accent surface is a `10%` fill with a `25%` border of the
same token: `bg-success/10 border-success/25 text-success`. That combination reads
correctly on both `--background` and `--card` without a second variant.
- **Alpha borders.** `--border` is always an alpha value so it composites over whatever
sits behind it. Never substitute a solid grey.
- **The dark theme is not an inversion.** `--accent` lightens from `#2563EB` to `#60A5FA`
specifically so it keeps contrast on near-black. Do not assume a token can be derived
from its counterpart.
- **No raw palette colours in product code.** `emerald-500`, `blue-600`, `zinc-200` and
friends do not follow the theme and appear in no palette, which makes them invisible to
this document.
### Contrast
Measured WCAG 2.1 ratios for the pairs that matter. Normal text needs 4.5:1; large text
and UI boundaries need 3:1.
| Pair | Light | Dark |
| :-------------------------- | :---------- | :---------- |
| Foreground on Background | 16.28 — AAA | 17.20 — AAA |
| Foreground on Card | 17.93 — AAA | 16.03 — AAA |
| Muted on Background | 6.06 — AA | 7.38 — AAA |
| Muted on Card | 6.68 — AA | 6.88 — AA |
| Accent on Background | 4.69 — AA | 7.44 — AAA |
| Accent-foreground on Accent | 4.98 — AA | 7.02 — AAA |
| Success on Background | 4.98 — AA | 9.84 — AAA |
| Warning on Background | 4.56 — AA | 11.34 — AAA |
| Destructive on Background | 5.88 — AA | 5.03 — AA |
| Destructive on Card | 6.47 — AA | — |
`--destructive` was `#DC2626` until October 2026, which measured 4.39:1 on the page
ground and failed AA for error text at body size. It is now `#B91C1C`. Status colour is
still never the only signal: pair it with an icon and wording that names the state.
---
## Typography
### Families
| Role | Family | Variable | Loaded by |
| :-------- | :--------- | :------------------ | :---------------------------------------------------- |
| Interface | Geist Sans | `--font-geist-sans` | `packages/ui/src/lib/fonts.ts` via `next/font/google` |
| Mono | Geist Mono | `--font-geist-mono` | same |
Consumed by `apps/site`, `apps/studio`, `apps/blog-platform`, and `apps/docs-platform` —
each spreads `globalFontVariables` onto its root element. `apps/portfolio` uses **Outfit**
instead; see [Portfolio](#portfolio).
Both Geist faces are released by Vercel under the SIL Open Font License 1.1 and are
available from Google Fonts. `next/font` self-hosts them at build time, so there is no
runtime request to Google and nothing to license to typeset the VeriWorkly name.
**Geist Sans** carries interface and body copy. **Geist Mono** carries labels, metadata,
file paths, dimensions, token names, and code — anything the reader should recognise as a
machine value rather than prose.
### Scale
Seven steps. Everything on a VeriWorkly surface should be one of them.
| Step | Tailwind | Rendered | Weight | Tracking | Line height | Use |
| :------------- | :-------------------------------------------------------------- | :-------------- | :----- | :------- | :---------- | :--------------------------------------- |
| **Display** | `text-4xl sm:text-5xl md:text-6xl font-semibold tracking-tight` | 36 → 48 → 60 px | 600 | −0.025em | 1.0 | Page-opening headline. One per page. |
| **Section** | `text-3xl font-semibold tracking-tight` | 30 px | 600 | −0.025em | 36 px | Section headings. |
| **Component** | `text-xl font-semibold tracking-tight` | 20 px | 600 | −0.025em | 28 px | Card and panel titles. |
| **Body Large** | `text-base leading-8 md:text-lg` | 16 → 18 px | 400 | 0 | 32 px | Lead paragraphs, marketing copy. |
| **Body** | `text-sm leading-relaxed` | 14 px | 400 | 0 | 1.625 | Descriptions, list items, dense UI. |
| **Eyebrow** | `text-xs font-semibold tracking-[0.24em] uppercase` | 12 px | 600 | 0.24em | 16 px | Label above a heading. Always uppercase. |
| **Mono Meta** | `font-mono text-xs` | 12 px | 400 | 0 | 16 px | Token names, paths, dimensions, code. |
The canonical copy of this table is `typeScale` in `apps/site/config/brand.ts`.
### Rules
- **Weights.** 400 for prose, 600 for every heading and label. 500 appears on some
interactive text; 700 and above are reserved for the share-card title, which is
optically compensating for downscaling in social feeds.
- **Tracking.** Headings tighten to `−0.025em`. Uppercase eyebrows open to `0.24em` —
uppercase without added tracking reads as shouting.
- **Measure.** Keep running prose near 65 characters. `max-w-2xl` on lead paragraphs,
`max-w-3xl` on explanatory blocks.
- **Balance.** Multi-line headings get `text-wrap: balance`.
- **Numerals.** Any column of digits gets `tabular-nums`.
---
## Spacing & layout
### Container
```tsx
<Container>…</Container> // packages/ui/src/components/layout/Container.tsx
```
Renders `mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8` — 1280 px maximum, with 16 px of
gutter on mobile, 24 px from `sm`, 32 px from `lg`. Accepts `as` to change the element. Do
not hand-roll a container; reach for this one so gutters stay identical across every page
and app.
### Breakpoints
Tailwind v4 defaults, unmodified.
| Prefix | Min width |
| :----- | :-------------- |
| `sm` | 640 px (40rem) |
| `md` | 768 px (48rem) |
| `lg` | 1024 px (64rem) |
| `xl` | 1280 px (80rem) |
| `2xl` | 1536 px (96rem) |
### Page rhythm
- **Marketing page shell:** `pt-28 pb-20 lg:pt-36` on the outer wrapper. The top padding
clears the fixed navbar.
- **Between sections:** `space-y-16 md:space-y-24`.
- **Inside a section:** `space-y-8`, with `space-y-4` to `space-y-6` inside a card.
- **Anchored sections:** `scroll-mt-24`, so a deep link does not land under the navbar.
- **Sibling groups** are spaced with flex or grid `gap`, not per-element margins — margins
collapse and double in ways that are hard to see and harder to fix.
### Secondary marketing pages
Every marketing page except the landing page, pricing, roadmap, `/style-guide` and
`/brand-kit` is built from `apps/site/components/marketing/PageLayout.tsx`. The system copies
the pricing page:
| Primitive | What it gives you |
| :--------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FRAME` | `mx-auto w-[min(1180px,calc(100%-32px))]`, the one frame every section sits in. |
| `PageHero` | Left-aligned display headline (`clamp(3rem,7.4vw,6.75rem)`, bold, `tracking-tighter`), last line in `--accent`; lede and actions to the right above a rule. |
| `PageSection` | A band with `py-24 lg:py-32` and a heading row: headline left, lede right. `tone` is `plain`, `card` or `accent` (one accent band per page). |
| `SectionHeading` | The same heading row on its own. |
| `PageClosing` | The last section: one ink panel with a headline, a line, "Open Studio" and one quiet link. The landing page keeps its own closer. |
`components/marketing/StatusPage.tsx` builds the 404, error and loading screens from the same
frame.
Type on these pages comes from `TYPE` in the same file, never from a one-off size:
| Step | Size (390 → 1440 px) | Use |
| :-------- | :------------------------ | :------------------------------------ |
| `display` | 44 → 88 px, bold, tight | The page's h1. One per page. |
| `section` | 32 → 52 px, bold, tight | A section's h2. |
| `figure` | 32 → 48 px, bold, tabular | A large number in a stat or figure. |
| `title` | 20 → 24 px, bold | A block or card heading in a section. |
| `lede` | 16 → 18 px | The paragraph under a headline. |
Body copy is 16 px; metadata and captions are 14 px. Nothing smaller.
Rules for these pages: keep headline lines short (they are set very large); separate sections
by a step in ground tone, not ornaments; no eyebrow labels, surface grid, blurred blobs or
icon-topped card grids; body copy at 16 px or more. Show the product with HTML fragments of
the real interface (marked `data-ui`) rather than icons.
Entrances are CSS only, in `apps/site/app/globals.css`: `.reveal` rises as the element scrolls
into view (scroll-driven, so it needs no JavaScript and does nothing where unsupported), and
`.hero-rise` plays once on load. Headlines never fade, because they are the largest paint.
### Radius scale
| Token | Value | Applied to |
| :------------- | :---- | :---------------------------------------------------------------- |
| `rounded-md` | 6 px | Checkbox |
| `rounded-lg` | 8 px | Small inputs |
| `rounded-xl` | 12 px | Medium inputs, menus, small tiles |
| `rounded-2xl` | 16 px | Large inputs, select, textarea, accordion, tooltip, nested panels |
| `rounded-3xl` | 24 px | `Card`, modal |
| `rounded-4xl` | 32 px | Major landing sections |
| `rounded-full` | pill | Buttons, badges, switch, chips |
Radius tracks size: the larger the surface, the larger the corner. Controls that accept
text are pills or 12–16 px; containers that hold other things are 24–32 px.
---
## Surfaces, elevation & patterns
### Surface grid
The signature background. 28 px, drawn from `--border` at 60% so it follows the theme.
```css
.surface-grid {
background-image:
linear-gradient(
to right,
color-mix(in srgb, var(--border) 60%, transparent) 1px,
transparent 1px
),
linear-gradient(
to bottom,
color-mix(in srgb, var(--border) 60%, transparent) 1px,
transparent 1px
);
background-position: center;
background-size: 28px 28px;
}
```
28 px is the system's grid unit. Share cards use it too — if you draw a grid anywhere, it
is 28 px.
### Page gradient
Applied globally to `body` in `packages/ui/src/styles/globals.css`. Two accent radials in
the top corners over the page ground.
```css
background:
radial-gradient(circle at top left, rgba(37, 99, 235, 0.12), transparent 28%),
radial-gradient(circle at top right, rgba(96, 165, 250, 0.08), transparent 22%), var(--background);
```
When reproducing it in a component, compose from tokens rather than baking the hexes, so
the swatch follows the theme:
```css
background:
radial-gradient(
circle at top left,
color-mix(in srgb, var(--accent) 12%, transparent),
transparent 28%
),
radial-gradient(
circle at top right,
color-mix(in srgb, var(--accent) 8%, transparent),
transparent 22%
),
var(--background);
```
### Card depth
A 145° linear gradient gives card surfaces a soft, paper-like fall-off.
```css
background: linear-gradient(
145deg,
var(--card),
color-mix(in oklab, var(--card) 88%, var(--foreground))
);
```
### Shadows
| Name | Value | Use |
| :-------------- | :----------------------------------------------- | :-------------------------------- |
| Card default | `shadow-sm` | Every `Card` |
| Premium / float | `shadow-[0_30px_90px_-50px_rgba(0,0,0,0.45)]` | Landing sections, floating panels |
| Accordion | `shadow-[0_12px_35px_-28px_rgba(15,23,42,0.65)]` | Accordion shell |
| Menu | `shadow-xl ring-1 ring-black/5` | Dropdowns |
| Modal | `shadow-2xl` | Dialog |
The premium shadow is deliberately large-blur and heavily negative-spread: it reads as
depth rather than as a drop shadow.
### Selection
```css
::selection {
background: color-mix(in srgb, var(--accent) 28%, transparent);
}
```
---
## Motion
Motion confirms an action; it never announces itself. Anything longer than 250 ms on an
interaction reads as latency.
| Token | Duration | Easing | Use |
| :--------------- | :--------- | :------------------------------ | :------------------------------------------------- |
| Micro transition | 200 ms | `ease` (default) | Hover and focus colour changes |
| Enter | 200 ms | `cubic-bezier(0.16, 1, 0.3, 1)` | Popovers, dropdowns, dialogs (`animate-scale-in`) |
| Shimmer | 2.2 s loop | `ease-in-out` | Skeleton placeholders (`animate-shimmer`) |
| Pulse ring | 2.4 s loop | `cubic-bezier(0.4, 0, 0.6, 1)` | Live / recording indicators (`animate-pulse-ring`) |
Registered in each app's `@theme` block. Canonical list: `motionTokens` in
`apps/site/config/brand.ts`.
Component-level transitions run at `duration-200`, occasionally `duration-300` for
accordion height. Transform-based hover affordances signal direction of travel — links
move right (`translate-x-0.5`), downloads move down (`translate-y-0.5`), external links
move up-right.
### Reduced motion
A global rule in `packages/ui/src/styles/globals.css` collapses every animation and
transition to `0.01ms` and disables smooth scrolling:
```css
@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;
}
}
```
You do not need to handle this per component. But **never encode meaning in motion
alone** — for some readers there will be none.
---
## Focus & accessibility
Every interactive element needs a visible keyboard focus state, on `:focus-visible` only
so a mouse click stays quiet.
```
focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background
```
Use `outline-hidden`, not `outline-none`. Tailwind rings are box-shadows, which Windows
forced-colors mode strips; `outline-hidden` leaves a transparent outline that the system
repaints as a visible one. `ring-offset-background` keeps the offset gap the page colour
instead of white.
The ring is solid `--accent`: a 40% alpha ring measures under 2:1 against the page and
fails the 3:1 a focus indicator needs. Form controls turn their border solid
`--accent` and add a soft `ring-accent/20` halo, because a field already has a border to
strengthen. Inside an `overflow: hidden` parent (accordion items, menu items) the ring is
`ring-inset` so it is not clipped.
Checklist for anything interactive:
- Reachable by keyboard, in a sensible tab order.
- Visible focus ring with an offset — never `outline: none` alone.
- Decorative icons get `aria-hidden="true"`; meaningful ones get a label.
- Icon-only controls get `aria-label`.
- Colour is never the only carrier of state — pair it with an icon, a label, or wording.
- Touch targets are at least 44 px tall (`size="md"` is 44 px, `size="lg"` is 48 px).
- Wide content (tables, code, diagrams) scrolls inside its own `overflow-x: auto`
container so the page body never scrolls sideways.
---
## Component library
Exported from `@veriworkly/ui` (`packages/ui/src/index.ts`). Reach for these before
writing a new one.
### Layout
| Component | Notes |
| :---------------- | :------------------------------------------------------------------------------------------- |
| `AppShell` | `flex min-h-dvh flex-col` with `navbar` / `footer` slots, a skip link, and the one `main`. |
| `Container` | 1280 px max, responsive gutters. See [Container](#container). |
| `MarketingNavbar` | The page `header`. Desktop `nav`, mobile panel (page behind it made `inert`), actions, logo. |
| `MarketingFooter` | Shared marketing footer: `nav` landmarks, `h2` column headings, contact in `<address>`. |
| `ThemeToggle` | Light / dark switch, toggling from `resolvedTheme`. Label names the next theme. |
| `SocialIcons` | Brand social icons. Decorative (`aria-hidden`) unless given `aria-label`. |
### Controls
**`Button`** — `rounded-full`, `font-medium`, solid `ring-accent` focus ring with an
offset, `active:scale-[0.97]` press feedback over 150 ms, `disabled:opacity-50`. With
`asChild`, the child's `className` and `onClick` merge with the button's, and a disabled
link leaves the tab order.
| Variant | Style |
| :---------- | :-------------------------------------------------------------------------- |
| `primary` | `bg-accent text-accent-foreground shadow-sm hover:opacity-90` |
| `secondary` | `bg-card text-foreground ring-1 ring-inset ring-border hover:bg-background` |
| `ghost` | `bg-transparent text-foreground hover:bg-card` |
| Size | Height | Padding | Text |
| :--- | :----- | :------ | :---- |
| `sm` | 36 px | 12 px | 14 px |
| `md` | 44 px | 16 px | 14 px |
| `lg` | 48 px | 20 px | 16 px |
Supports `asChild` (clones the child — e.g. a `Link` — with the button classes), `loading`,
and `loadingText`. **One primary button per view.**
**`Input`** — variants `outline` (default), `filled`, `ghost`; sizes `sm` (36 px,
`rounded-lg`), `md` (44 px, `rounded-xl`), `lg` (48 px, `rounded-2xl`). Takes `error`,
which sets `aria-invalid` and swaps the border and ring to `--destructive`; setting
`aria-invalid` directly does the same. Note the prop is `inputSize`, not `size`, because
`size` collides with the native attribute.
All three text fields share `components/ui/field.ts`: placeholder in `text-muted`, a
`hover:border-foreground/25` hover state, and text forced to 16 px below `sm` because iOS
Safari zooms the page into any smaller focused field.
**`TextArea`** — `min-h-30` (120 px), `rounded-2xl`, non-resizable, same error treatment.
**`Select`** — a native `<select>`, 44 px, `rounded-2xl`, same focus and error treatment.
**`Checkbox`** — 20 px, `rounded-md`, `border-2`, 40 px pointer target; checked and
indeterminate both fill with `--accent`. Controlled (`checked`) or uncontrolled
(`defaultChecked`). Tick and dash cross-fade over 150 ms.
**`Switch`** — `role="switch"`, 24 × 44 px track, 20 px knob, `bg-accent` on /
`bg-muted/80` off, 200 ms. Focus ring on keyboard focus only. Has no visible text: name it
with `aria-label` or `aria-labelledby`.
**`Badge`** — `rounded-full border px-3 py-1 text-xs font-medium`. `variant` is `neutral`
(default), `accent`, `success`, `warning`, or `destructive`, each the tinted-fill triplet.
### Surfaces & overlays
**`Card`** — `bg-card border-border/50 rounded-3xl p-5 shadow-sm overflow-hidden`. The
default padding is routinely overridden (`p-6`, `p-8`), and `p-0` is used when children
own their own padding — a divided list, for instance. Pass `as="article"` for a
self-contained piece of content.
**`Modal`** — portaled to `body` at `z-200`, above the navbar and below toasts.
`bg-black/50 backdrop-blur-sm` overlay; a bottom sheet on mobile (`items-end`,
`rounded-t-3xl`, swipe-down handle) that becomes a centred `max-w-lg rounded-3xl` dialog
from `md`. `Modal.Title` and `Modal.Description` label the dialog automatically. Escape
and the focus trap act on the topmost modal only.
**`Menu`** — absolutely positioned, `z-30`, `rounded-xl` / `rounded-2xl` by size,
`shadow-xl ring-1 ring-black/5`, scales in from its trigger's corner over 150 ms; items
are `rounded-xl` with `hover:bg-accent/10`; separators are `bg-border h-px`. Follows the
APG menu-button pattern (arrow keys, Home/End, typeahead, Escape returns focus).
**`Accordion`** — `rounded-2xl border-border/70 bg-card/90`; animates `grid-template-rows`
over 300 ms; the chevron rotates 180°. Collapsed panels are `hidden="until-found"`, so
find-in-page reaches every answer and opens it.
**`Tooltip`** — `rounded-2xl`, `max-w-70` (280 px), `bg-foreground text-background`:
deliberately **inverted** against the page, near-black in light mode and near-white in
dark, so it reads as separate from the surface beneath it. Rendered in the top layer with
the Popover API; linked by `aria-describedby`; hoverable and dismissible with Escape
(WCAG 1.4.13).
### Icons
**Lucide React** (`lucide-react` ^1.27), 1.5 px stroke, sized in 4 px steps from 16 to
24 px (`size-4`, `size-5`, `size-6`). Icons inherit `currentColor`.
---
## Share cards (Open Graph)
Generated on request by `apps/site/app/api/og/route.tsx` (edge runtime, Satori) rather
than designed one at a time, so no page ships without one.
| Property | Value |
| :---------- | :-------------------------------------------------------------------------------------------------------------- |
| Canvas | 1200 × 630 (1.91:1) |
| Composition | Centred: pill badge, title, description, with the domain locked to the bottom |
| Ground | `--background`, two accent radials in the top corners, 28 px grid |
| Badge | Inline mono mark at 34 px + "VeriWorkly" in mono 700, on a token-tinted pill |
| Title | 84 px / weight 900 / `−0.05em`, dropping to 60 px past 40 characters, filled with a Foreground → Muted gradient |
| Description | 32 px / weight 500 / `--muted`, `max-width` 850 px |
| Footer | `veriworkly.com`, 18 px, `0.2em` tracking, uppercase, 50% opacity |
| Cache | `public, immutable, max-age=31536000` |
| Parameter | Behaviour |
| :------------ | :---------------------------------------------- |
| `title` | Up to 120 characters. Defaults to "VeriWorkly". |
| `description` | Up to 250 characters. |
| `theme` | `light` (default) or `dark`. |
| `showDesc` | `false` for a title-only card. |
Two constraints worth knowing before editing this route:
1. **Satori has no CSS custom properties.** The theme values are repeated as literals in a
`t` object at the top of the route. They must stay equal to `themes.css`.
2. **No network dependencies.** The mark is drawn as an inline `<path>`, not fetched as a
PNG. A remote image that fails to resolve does not fail the render — it silently
produces a card with no logo. The faceted mark needs a `clipPath` and seven gradients
that Satori does not support reliably, which is why the badge uses the single-colour
mark.
Text is stripped of control, zero-width, and bidi-override codepoints and length-capped,
so the endpoint cannot be used to mint a convincing card that says something we did not
write.
---
## Surfaces & sub-brands
One system, five surfaces. None of them introduces a separate brand.
| Surface | App | Font | Tokens |
| :----------- | :------------------- | :--------- | :--------------------------- |
| Marketing | `apps/site` | Geist | Shared |
| Studio (app) | `apps/studio` | Geist | Shared |
| Docs | `apps/docs-platform` | Geist | Shared + `--fd-*` (Fumadocs) |
| Blog | `apps/blog-platform` | Geist | Shared |
| Portfolio | `apps/portfolio` | **Outfit** | **Parallel — see below** |
### Portfolio
The portfolio product is a system-managed app surface that extends the platform without
introducing a separate brand.
- **Marketing pages** — asymmetric editorial product tour with a structured template
gallery.
- **App pages** — workbench layout: grouped editing cards, contextual help, publish
readiness, and a persistent private preview.
- **Public templates** — may use distinct local palettes and typography, so portfolio
owners get a real creative choice. Template palettes must be declared as named CSS
tokens in each template stylesheet (`apps/portfolio/template-library/*/`).
- **Motion** — CSS-first reveal, hover lift, and state transitions. Spatial motion
collapses under `prefers-reduced-motion`.
- **Editor stance** — section-aware inputs, plain-language guidance, compatible snapshot
parsing, visible focus, and no destructive action without a label.
Portfolio declares its own token layer in `apps/portfolio/app/tokens.css` under a different
vocabulary — `paper`, `panel`, `ink`, `line` instead of `background`, `card`, `foreground`,
`border` — plus its own spacing scale (`--space-xs` … `--space-4xl`, 4 → 112 px), radius
scale (`--radius-xs` … `--radius-xl`, 6 → 20 px), easings, and durations (`--dur-fast`
160 ms, `--dur-medium` 320 ms, `--dur-slow` 700 ms).
The underlying colour values are the platform palette:
| Portfolio | Platform equivalent |
| :------------------- | :-------------------------------------------- |
| `--color-paper` | `--background` (`#F5F4EF` / `#0D1117`) |
| `--color-panel` | `--card` (`#FFFFFF` / `#121924`) |
| `--color-ink` | `--foreground` (`#171717` / `#F3F4F6`) |
| `--color-muted` | `--muted` (`#5F5C54` / `#94A3B8`) |
| `--color-line` | `--border` (12% / 24% alpha) |
| `--color-accent` | `--accent` (`#2563EB` / `#60A5FA`) |
| `--color-accent-ink` | `--accent-foreground` (`#F8FBFF` / `#0F172A`) |
Portfolio's `--color-success` / `--color-warning` / `--color-danger` are authored in `oklch`
and are **not** the platform's `--success` / `--warning` / `--destructive`. Treat them as
template-layer colours, not platform status.
---
## Voice
How we write about VeriWorkly, in product and in public.
**Direct, not hypey.** Lead with what the product does, not adjectives. "No login required
to start" beats "revolutionary, game-changing career platform."
**Privacy-forward.** Be specific about what stays local, what syncs, and why. Vague privacy
claims read as marketing; specifics read as true.
**Built by the same people who use it.** Written by people who build resumes, not a brand
team. Plain language over jargon; explain ATS, JSON Resume, and similar on first use.
**Confident about being free and open.** "Free-to-use and open-core" is a real
differentiator, not an apology. State it plainly rather than hedging.
### UI copy
- Write from the reader's side of the screen. A person manages _notifications_, not
_webhook config_.
- Active voice. A control says exactly what happens — "Publish", then a toast that says
"Published".
- Errors explain what went wrong and how to fix it. No apologies, no vagueness.
- Specific beats clever.
### Product terms
| Term | Meaning |
| :-------------------- | :------------------------------------------------------------------------------------------------------------------ |
| **VeriWorkly** | The company and product. One word, capital V and W. |
| **VeriWorkly Resume** | Only when disambiguating from other VeriWorkly products (e.g. the repo name). |
| **Master Profile** | The canonical career-facts record that seeds resumes, cover letters, and portfolios. Capitalised as a product term. |
### Boilerplate
- **One-liner** — Free AI resumes, cover letters & web portfolios. No login required.
- **Short** — VeriWorkly is a free, open-core, privacy-first AI career workspace — a resume,
cover letter, and portfolio builder that requires no login to start and keeps data
local-first with optional cloud sync.
- **Long** — see `siteConfig.description` in `apps/site/config/site.ts`.
Press and brand enquiries: `info@veriworkly.com`.
---
## Changing the system
### Adding or changing a colour token
1. Edit `packages/ui/src/styles/themes.css` — **both** `:root` and `.dark`.
2. Mirror the change in `brandColors` in `apps/site/config/brand.ts`, with a description
and a `core` flag (`true` puts it on `/brand-kit`; every token appears on
`/style-guide`).
3. If it needs a Tailwind utility, add `--color-<name>: var(--<name>)` to the
`@theme inline` block in `apps/site`, `apps/studio`, `apps/blog-platform`, and
`apps/docs-platform`. Skipping this leaves a dead class.
4. Check contrast against `--background` and `--card` in both themes.
5. Run `npm run build:brand-kit` from `apps/site` to regenerate the download.
### Adding a logo asset
1. Drop the file in `apps/site/public/brand/logo/`.
2. Add it to `logoAssets` in `apps/site/config/brand.ts` with its size, format, and a
one-line usage note.
3. Run `npm run build:brand-kit`. The script fails loudly if a referenced file is missing,
so the manifest and the folder cannot drift.
### Rebuilding the brand kit
```bash
cd apps/site && npm run build:brand-kit
```
Writes `public/brand/veriworkly-brand-kit.zip` — logos, `palette.json`, and a generated
`README.txt` — from `config/brand.ts`. Output is byte-identical across runs (timestamps are
pinned), so a rebuild with no content change produces no diff. The ZIP writer is
dependency-free and lives in `apps/site/scripts/build-brand-kit.mts`.
Never hand-edit anything inside the archive. The README says so too.
### Verifying the system agrees with itself
```bash
cd apps/site && npm run check:design
```
Compares all three descriptions of the palette — `themes.css`, `brand.ts`, and the tables
in this document — token by token, and reports any token that is defined but never mapped
into a Tailwind `@theme` block. Exits non-zero on drift, so it belongs in CI.
Two tokens are expected in the "not mapped" list: `--fd-accent`, which Fumadocs reads as a
raw CSS variable rather than a utility, and `--muted-foreground`, which is a real gap — see
[Known deviations](#known-deviations).
### Review checklist
Before merging anything visual:
- [ ] Renders correctly in **both** themes — not just the one you were working in.
- [ ] No raw Tailwind palette colours (`emerald-500`, `blue-600`, `zinc-200`).
- [ ] Type is one of the seven scale steps.
- [ ] Interactive elements have a visible `:focus-visible` ring.
- [ ] Spacing comes from layout `gap`, not ad-hoc margins.
- [ ] Wide content scrolls in its own container.
- [ ] Any new token is mapped in all four `@theme` blocks.
- [ ] `npm run check:design` passes.
---
## Known deviations
An honest list of places the codebase does not yet match this document. Each is real and
verified; none is a reason to copy the pattern.
**`--muted-foreground` is a trap.** It is `#171717` in both themes (text on a muted
fill), so as a text colour on the dark ground it is unreadable. `apps/studio` and
`apps/blog-platform` sidestep it by mapping the utility to `--muted` instead. Shared
components no longer use it.
**The wordmark is set in Geist Mono.** [Logo](#logo) says "Geist Sans Semibold beside the
mark", but every live surface (the site's `LogoPill`, `MarketingNavLogo`,
`MarketingFooter`) sets it in `font-mono`. Either the rule or the surfaces should change;
that is a brand decision, not a code fix.
**Raw blue utilities in the shared chrome.** Roughly 406 occurrences of `text-blue-600`,
`dark:text-blue-400`, `border-blue-500/*`, and `bg-blue-500/*` across 30+ files in
`apps/site` hand-roll the accent token at approximately its own values. Converting them to
`text-accent` is mechanical but touches a lot of surface, so it wants to be its own
reviewed change.
**Portfolio duplicates the scale.** `apps/portfolio/app/tokens.css` restates the palette
under different names and adds parallel spacing, radius, easing, and duration scales. This
is defensible — templates need their own vocabulary — but it means a platform token change
has to be applied in two places, and portfolio's `oklch` status colours do not match the
platform's.