Skip to content
yf v0.1.0

design system

yf

The visual language of yoanfaure.com, extended for tools. Ink on paper, a square layout, plain CSS and a little vanilla JavaScript. Nothing to install, nothing to build.

version
v0.1.0, pinned: there is no latest
made for
webapps on *.yoanfaure.com, and Claude artifacts
licence
MIT. Inter and JetBrains Mono are OFL 1.1. Icons are Phosphor, MIT.
source
github.com/y0anfa/yf

Getting started

Install

Link a pinned version. There is nothing to install and nothing to build.

html
<link rel="stylesheet" href="https://ds.yoanfaure.com/v0.1.0/yf.css">
<script src="https://ds.yoanfaure.com/v0.1.0/yf.js" defer></script>

yf.css is everything: tokens, base styles, layout and every component. It finds Inter and JetBrains Mono in fonts/ next to itself, so there is no second stylesheet and no font request to set up. yf.js is optional. Every component reads correctly without it; the script adds the theme switch, tabs, menus, dialogs, toasts, sortable tables, copy buttons, sparklines, charts and the topo field.

Every file under a version is served with open CORS and a one-year immutable cache, so an app on another subdomain loads them straight from here.

yf.js applies the saved theme the moment it runs. In <head> without defer that happens before first paint; with defer, a reader who chose dark can see one light frame on reload. This page loads it without defer for that reason.

yf runs under a strict content security policy: no inline script, no inline style, no eval, no innerHTML with data. This page is served with default-src 'none', script-src 'self' and style-src 'self', and it works. If you load it from here, your policy needs to allow this host:

http
Content-Security-Policy: style-src 'self' https://ds.yoanfaure.com; script-src 'self' https://ds.yoanfaure.com; font-src https://ds.yoanfaure.com

a starting page

html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>My tool</title>
  <link rel="stylesheet" href="https://ds.yoanfaure.com/v0.1.0/yf.css">
  <script src="https://ds.yoanfaure.com/v0.1.0/yf.js"></script>
</head>
<body>
  <main class="yf-container yf-stack">
    <h1>My tool</h1>
    <button class="yf-button yf-button--primary" type="button">Get started</button>
  </main>
</body>
</html>

Pinned versions only

There is no latest alias, on purpose. A released version never changes: https://ds.yoanfaure.com/v0.1.0/yf.css is the same file today and in five years, so a page that links it cannot break because yf moved. To upgrade, change the version in the URL, then look at the page.

Versions follow semver. Patch releases fix bugs. Minor releases add components, variants and tokens. Major releases may rename or remove classes, tokens, data attributes or JavaScript APIs. Before 1.0 a minor release may break things too. A fix never reaches a page that pins an older version; that is the trade.

This page documents v0.1.0. Released versions:

versionreleasedfiles
v0.1.0 latest2026-10-29yf.css, yf.js, icons, RELEASE

Icon sprite

Icons are Phosphor Regular in one SVG sprite, icons/yf-icons.svg. Reference a symbol with <use>. An icon takes its size from CSS (1rem, or .yf-icon--sm, --lg, --xl) and its colour from the text around it. The whole set is in icons.

html
<div class="yf-cluster">
  <svg class="yf-icon yf-icon--sm" aria-hidden="true"><use href="icons/yf-icons.svg#shield-check"/></svg>
  <svg class="yf-icon" aria-hidden="true"><use href="icons/yf-icons.svg#shield-check"/></svg>
  <svg class="yf-icon yf-icon--lg" aria-hidden="true"><use href="icons/yf-icons.svg#shield-check"/></svg>
  <svg class="yf-icon yf-icon--xl" aria-hidden="true"><use href="icons/yf-icons.svg#shield-check"/></svg>
  <svg class="yf-icon yf-icon--lg" role="img" aria-label="Verified"><use href="icons/yf-icons.svg#check-circle"/></svg>
</div>

An icon beside text is decoration: aria-hidden="true". An icon on its own needs role="img" and an aria-label.

From another origin, yf.js brings the sprite in

Browsers refuse a <use> that points at another origin, so href="https://ds.yoanfaure.com/v0.1.0/icons/yf-icons.svg#check" alone would not work from your app. yf.js handles it: when the page has a .yf-icon, it fetches the sprite that sits next to it, keeps only the drawing, and puts the symbols in the page once. Write <use href="#check">. A full sprite URL is rewritten to #check too, but the browser logs one refusal before that, so prefer the short form. Add the sprite's origin to your connect-src. To skip the fetch, set <html data-yf-icons="off"> and paste the symbols you use into the page, which is what Claude artifacts do.

html
<svg class="yf-sprite" aria-hidden="true">
  <!-- paste the <symbol> elements from yf-icons.svg here -->
</svg>

<svg class="yf-icon" aria-hidden="true"><use href="#check"/></svg>

Using yf in Claude artifacts

An artifact cannot load an external stylesheet: its content security policy allows stylesheets from Google Fonts only. So an artifact inlines yf.css in a <style> block and loads Inter and JetBrains Mono from Google Fonts.

The three @font-face rules at the top of yf.css point at fonts/ by a relative URL, which will not resolve in an artifact. Delete them from the inlined copy, and the Google Fonts families of the same names apply. Inline yf.js the same way if the artifact needs behaviour, and paste the sprite inline as shown in icon sprite.

html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=JetBrains+Mono:wght@400&display=swap">
<style>
  /* the contents of yf.css, without its three @font-face rules */
</style>

The result is the same page. Only where the files come from changes.

Theming

Light and dark. The system decides, unless data-theme="light" or data-theme="dark" is set on <html>. With yf.js, a toggle sets it and remembers the choice in localStorage under yf-theme. The switch is one short cross-fade, and none when the reader prefers reduced motion. Try these; they change this page.

html
<div class="yf-button-group" role="group" aria-label="Theme">
  <button class="yf-button" type="button" data-yf-theme-toggle="light">Light</button>
  <button class="yf-button" type="button" data-yf-theme-toggle="system">System</button>
  <button class="yf-button" type="button" data-yf-theme-toggle="dark">Dark</button>
</div>
js
yf.theme.set('dark');      // 'light', 'dark' or 'system'
yf.theme.toggle();         // flips between light and dark
yf.theme.get();            // what the reader saved: 'light', 'dark' or 'system'
yf.theme.resolved();       // what is shown: 'light' or 'dark'
document.addEventListener('yf:theme', (e) => console.log(e.detail)); // { theme, resolved }

Tokens are set on :root, so a theme applies to the whole page; a section cannot take the other one.

Cascade layers

Everything yf ships sits in a cascade layer, in this order, weakest first: yf.reset, yf.tokens, yf.base, yf.layout, yf.components, yf.utilities. CSS that is in no layer beats every layer, whatever its specificity, so your app's CSS always wins. No !important, no selector arms race.

css
/* unlayered, so these win */
:root { --yf-radius: 6px; }                 /* change a token */
.yf-button { letter-spacing: 0.02em; }      /* change a component */

JavaScript

Behaviour is added by data-yf-* attributes and starts on DOMContentLoaded. Each part is also on window.yf. Markup you add later: call the matching init(root) with the new element.

behaviourmarkupapi
themedata-yf-theme-toggle="light|dark|system"yf.theme.set(mode), get(), resolved(), toggle(); event yf:theme
toastnoneyf.toast({ message, status, action, timeout }), yf.toast.dismissAll()
dialogdata-yf-dialog-open="id", data-yf-dialog-close, data-yf-dialog-staticyf.dialog.open(id), yf.dialog.close(id)
tabsdata-yf-tabsyf.tabs.init(root), yf.tabs.select(tab); event yf:tabchange
menupopover on .yf-menu, data-yf-keep-openyf.menu.init(root)
tooltipdata-yf-tooltip, data-yf-tooltip-posyf.tooltip.init(root)
sortable tabledata-yf-sortable, data-sort-valueyf.table.init(root), yf.table.sort(table, column, direction); event yf:sort
copydata-yf-copy, data-yf-copy-label, data-yf-copiedyf.copy.init(root), yf.copy.write(text)
sparklinedata-yf-sparkline, data-yf-threshold, data-yf-labelyf.sparkline.init(root), yf.sparkline.draw(host)
chartdata-yf-chart="line|bar" with a <script type="application/json"> spec insideyf.chart.line(el, spec), yf.chart.bar(el, spec), each returning { update(spec), destroy() }; yf.chart.init(root)
topo fielddata-yf-field, data-yf-seed, data-yf-static, data-yf-pointer="off"yf.field.mount(el, options), yf.field.init(root), yf.field.destroy(el)

Foundations

Colour

Ink on paper, in two themes. Nothing is coloured to look nice: the only colours in yf are the three status colours below. The primary action is solid ink, and focus is a 2px ink outline with a 2px offset.

light

Swatchtokenvalueon paper
--yf-bgpaper: the page#f4efe4paper
--yf-fgink: text, rules, the primary action#26252313.3:1
--yf-mutedsecondary text#6b675f4.9:1
--yf-ruledividers, a decoration#ddd6c81.2:1
--yf-sunkpanels, code, controls#ece6d81.0:1
--yf-control-borderthe edge of a control#83807a3.4:1
--yf-hoverhover fill (#262523 at 6%)#e8e3d8fill
--yf-selectedselected fill (#262523 at 11%)#ddd9cffill

dark

Swatchtokenvalueon paper
--yf-bgpaper: the page#1e1d1bpaper
--yf-fgink: text, rules, the primary action#ece6d913.5:1
--yf-mutedsecondary text#9a958b5.6:1
--yf-ruledividers, a decoration#34322f1.3:1
--yf-sunkpanels, code, controls#2625221.0:1
--yf-control-borderthe edge of a control#8f8c845.0:1
--yf-hoverhover fill (#ece6d9 at 6%)#2a2926fill
--yf-selectedselected fill (#ece6d9 at 11%)#353330fill

Text needs 4.5:1 against paper and the edge of a control 3:1. Rules and fills are decoration, so they are quieter on purpose. Ratios are computed from the tokens in this release's yf.css, rounded down. --yf-primary is --yf-fg and --yf-on-primary is --yf-bg.

Status colours

Status only. Muted red, amber and green mean one thing: status. They appear on pills, errors, toasts, banner marks and chart thresholds. They are never decoration, never brand, and never alone: a status is always text plus a form, a label, an icon or a glyph.

Critical Warning Passed Ignored

light

Swatchtokenon paperon fill
--yf-danger#a12c1b--yf-danger-bg#f1d9cf6.3:15.3:1
--yf-warning#855700--yf-warning-bg#efdfba5.4:14.7:1
--yf-success#3d6b2a--yf-success-bg#dbe5cc5.4:14.8:1

dark

Swatchtokenon paperon fill
--yf-danger#ec8a74--yf-danger-bg#3b24206.7:15.7:1
--yf-warning#dcab4f--yf-warning-bg#3a2f1b8.0:16.2:1
--yf-success#98c27c--yf-success-bg#2632218.2:16.6:1

Type

Inter for everything, self-hosted, as a variable font with its italic. JetBrains Mono, at one weight (400), for code, ids, hashes and numbers in tables. Headings are bold and tight: h1 is 3xl, h2 2xl, h3 xl, h4 lg, h5 base at 600, and h6 small and muted.

specimenclassvaluepixels
Ink on paper.yf-text-xs--yf-text-xs0.75rem12px
Ink on paper.yf-text-sm--yf-text-sm0.875rem14px
Ink on paper.yf-text-base--yf-text-base1rem16px
Ink on paper.yf-text-lg--yf-text-lg1.125rem18px
Ink on paper.yf-text-xl--yf-text-xl1.375rem22px
Ink on paper.yf-text-2xl--yf-text-2xl1.75rem28px
Ink on paper.yf-text-3xl--yf-text-3xlclamp(1.875rem, 1.3rem + 2.2vw, 2.5rem)30 to 40px
--yf-leading-tight
1.15
--yf-leading-normal
1.6
--yf-leading-prose
1.7
--yf-weight-regular
400
--yf-weight-medium
600
--yf-weight-bold
700
--yf-tracking-tight
-0.02em
--yf-tracking-display
-0.03em

utilities

Eyebrow: a small label above a section

Body text in Inter, with inline code and ⌘ K.

Muted, for the small stuff.

a1b2c3d mono, for ids and hashes

1,234.50 tabular figures

Bold

html
<div class="yf-stack yf-stack--sm">
  <p class="yf-eyebrow">Eyebrow: a small label above a section</p>
  <p>Body text in Inter, with <code>inline code</code> and <kbd>⌘</kbd> <kbd>K</kbd>.</p>
  <p class="yf-muted">Muted, for the small stuff.</p>
  <p class="yf-mono">a1b2c3d  mono, for ids and hashes</p>
  <p class="yf-num">1,234.50  tabular figures</p>
  <p class="yf-bold">Bold</p>
</div>

Spacing

One scale for gaps and padding. Space between siblings is always gap, never a margin: use stack, cluster and grid.

Sizetokenvaluepixels
--yf-space-10.25rem4px
--yf-space-20.5rem8px
--yf-space-30.75rem12px
--yf-space-41rem16px
--yf-space-51.5rem24px
--yf-space-62rem32px
--yf-space-73rem48px
--yf-space-84rem64px

Radius

The layout is square: pages, tables, panels and dialogs have radius 0 and 1px rules. Controls (buttons, inputs, pills, menus and toasts) have 3px. Overlays are the only things that cast a shadow.

layout: 0
pill
--yf-radius
3px
--yf-radius-layout
0
--yf-border
1px solid var(--yf-rule)
--yf-border-strong
1px solid var(--yf-fg)
--yf-control-h
2.25rem
--yf-measure
38rem
--yf-container
72rem
--yf-gutter
clamp(1rem, 4vw, 2.5rem)

Motion

Calm and functional. Three durations, an ease-out for things arriving and an ease-in for things leaving. With prefers-reduced-motion: reduce transitions drop out and the field shows one still frame.

--yf-dur-fast
--yf-dur
--yf-dur-slow
--yf-dur-fast
120ms
--yf-dur
200ms
--yf-dur-slow
400ms
--yf-ease-out
cubic-bezier(0.2, 0, 0, 1)
--yf-ease-in
cubic-bezier(0.3, 0, 0.8, 0.15)

Icons

70 icons: a curated subset of Phosphor Regular (MIT), in one sprite. The copy button copies the name; how to use it is in icon sprite.

actions

  • magnifying-glass
  • plus
  • minus
  • x
  • check
  • copy
  • trash
  • pencil-simple
  • download-simple
  • upload-simple
  • arrows-clockwise

direction

  • caret-down
  • caret-up
  • caret-left
  • caret-right
  • arrow-right
  • arrow-left
  • arrow-up
  • arrow-down
  • arrow-up-right
  • arrow-square-out
  • sign-out

layout and controls

  • gear-six
  • funnel
  • sliders-horizontal
  • dots-three
  • dots-three-vertical
  • list
  • squares-four
  • rows

status

  • warning
  • warning-circle
  • info
  • check-circle
  • x-circle
  • question

security

  • lock-simple
  • lock-simple-open
  • key
  • shield
  • shield-check
  • shield-warning
  • fingerprint
  • eye
  • eye-slash
  • bug

people and messages

  • user
  • users
  • bell
  • envelope-simple
  • link

code and data

  • terminal-window
  • code
  • git-branch
  • git-commit
  • file-text
  • folder-simple
  • database
  • cloud
  • globe-simple
  • chart-line
  • chart-bar

time and media

  • calendar-blank
  • clock
  • play
  • pause
  • stop

theme and places

  • sun
  • moon
  • house

Layout

Small boxes that place things. Spacing is always gap. Each has a custom property for the one-off case: --yf-stack-gap, --yf-cluster-gap, --yf-grid-min, --yf-split-side.

stack and cluster

stack: children in a column
with a gap between them
clusteritems thatwrapwhen theyrun out of room
--betweenpushes to the ends
html
<div class="yf-stack">
  <div>stack: children in a column</div>
  <div>with a gap between them</div>
</div>
<div class="yf-cluster">
  <span>cluster</span><span>items that</span><span>wrap</span><span>when they</span><span>run out of room</span>
</div>
<div class="yf-cluster yf-cluster--between">
  <span>--between</span><span>pushes to the ends</span>
</div>

grid and split

grid
as many columns
as fit
none narrower
than the minimum
split: sidebar
main. They stack when main would get under half the width, in the panel's own width, not the screen's.
html
<div class="yf-grid yf-grid--sm">
  <div>grid</div><div>as many columns</div><div>as fit</div><div>none narrower</div><div>than the minimum</div>
</div>
<div class="yf-split">
  <div>split: sidebar</div>
  <div>main. They stack when main would get under half the width, in the panel's own width, not the screen's.</div>
</div>

The sidebar is 15rem; .yf-split--wide-side makes it 20rem (a search with its filters). .yf-split--end puts it last.

container, column, divider

column: the reading measure (38rem), centred


ab
html
<div class="yf-stack">
  <div class="yf-column"><div>column: the reading measure (38rem), centred</div></div>
  <hr class="yf-divider">
  <hr class="yf-divider yf-divider--strong">
  <div class="yf-cluster"><span>a</span><div class="yf-divider yf-divider--vertical" role="separator"></div><span>b</span></div>
</div>

.yf-container is the app width with gutters: 72rem, --narrow 52rem, --wide 88rem for a table with many columns. .yf-page on the outer wrapper gives the page its room above and below. These presets exist because a strict CSP forbids a style attribute; any other value is --yf-container in a stylesheet. .yf-skip-link goes first on a page and shows when it has focus.

scroller

html
<div class="yf-scroller" aria-label="Tags">
  <button class="yf-button yf-button--sm" type="button" aria-pressed="true">all</button>
  <button class="yf-button yf-button--sm" type="button" aria-pressed="false">cloud</button>
  <button class="yf-button yf-button--sm" type="button" aria-pressed="false">code review</button>
  <button class="yf-button yf-button--sm" type="button" aria-pressed="false">detection</button>
  <button class="yf-button yf-button--sm" type="button" aria-pressed="false">iam</button>
  <button class="yf-button yf-button--sm" type="button" aria-pressed="false">incident</button>
  <button class="yf-button yf-button--sm" type="button" aria-pressed="false">policy</button>
  <button class="yf-button yf-button--sm" type="button" aria-pressed="false">threat model</button>
  <button class="yf-button yf-button--sm" type="button" aria-pressed="false">vendors</button>
  <button class="yf-button yf-button--sm" type="button" aria-pressed="false">vulnerabilities</button>
</div>

One row that scrolls sideways instead of wrapping. The scrollbar is hidden, so the edge that has more to show fades out. Tab lists, and the stats row on a phone, scroll the same way.

Components

Every component has hover, focus, active and disabled states, and aria-invalid or aria-busy where they apply. Each example below is live, and the markup under it is the markup that runs.

Actions

One primary button per view. Secondary is the default. Ghost is for the quiet second action beside a primary. Danger is for destructive actions, and should lead to a confirmation or an undo.

variants

html
<div class="yf-cluster">
  <button class="yf-button yf-button--primary" type="button">Save changes</button>
  <button class="yf-button" type="button">Cancel</button>
  <button class="yf-button yf-button--ghost" type="button">Skip for now</button>
  <button class="yf-button yf-button--danger" type="button">Delete report</button>
</div>

compact

html
<div class="yf-cluster yf-cluster--sm">
  <button class="yf-button yf-button--sm" type="button">Export</button>
  <button class="yf-button yf-button--sm" type="button" aria-pressed="true">Open <span class="yf-count">12</span></button>
  <button class="yf-button yf-button--sm" type="button" aria-pressed="false">Fixed <span class="yf-count">48</span></button>
  <button class="yf-button yf-button--sm yf-button--ghost yf-button--close" type="button" aria-label="Clear filters"></button>
</div>

.yf-button--sm is 28px tall, for filter chips and dense toolbars; a chip is a compact button with aria-pressed. .yf-count puts a number after a label, in a button, a tab or a nav link.

icons

html
<div class="yf-cluster">
  <button class="yf-button yf-button--primary" type="button"><svg class="yf-icon" aria-hidden="true"><use href="icons/yf-icons.svg#plus"/></svg>New report</button>
  <button class="yf-button" type="button">Next<svg class="yf-icon" aria-hidden="true"><use href="icons/yf-icons.svg#arrow-right"/></svg></button>
  <button class="yf-button yf-button--icon" type="button" aria-label="Copy" data-yf-tooltip="Copy"><svg class="yf-icon" aria-hidden="true"><use href="icons/yf-icons.svg#copy"/></svg></button>
  <button class="yf-button yf-button--ghost yf-button--icon" type="button" aria-label="Settings"><svg class="yf-icon" aria-hidden="true"><use href="icons/yf-icons.svg#gear-six"/></svg></button>
  <button class="yf-button yf-button--danger yf-button--icon" type="button" aria-label="Delete"><svg class="yf-icon" aria-hidden="true"><use href="icons/yf-icons.svg#trash"/></svg></button>
  <button class="yf-button yf-button--ghost yf-button--close" type="button" aria-label="Close"></button>
</div>

An icon-only button needs an aria-label. .yf-button--close draws its own cross and needs no sprite.

states

Anchor button
html
<div class="yf-cluster">
  <button class="yf-button yf-button--primary" type="button" disabled>Disabled</button>
  <button class="yf-button" type="button" aria-disabled="true">Aria disabled</button>
  <button class="yf-button" type="button" aria-busy="true">Saving</button>
  <button class="yf-button yf-button--danger" type="button" aria-busy="true">Deleting</button>
  <button class="yf-button" type="button" aria-pressed="true">Pressed</button>
  <a class="yf-button" href="#actions">Anchor button</a>
</div>

Prefer aria-disabled="true" when the button should stay focusable to explain itself. aria-busy="true" swaps the label for a spinner and keeps the width. aria-pressed="true" makes a toggle and fills it with ink.

groups and links

An anchor link
html
<div class="yf-cluster yf-cluster--lg">
  <div class="yf-button-group" role="group" aria-label="Range">
    <button class="yf-button" type="button" aria-pressed="true">Day</button>
    <button class="yf-button" type="button" aria-pressed="false">Week</button>
    <button class="yf-button" type="button" aria-pressed="false">Month</button>
  </div>
  <div class="yf-button-group" role="group" aria-label="Page">
    <button class="yf-button yf-button--icon" type="button" aria-label="Previous"><svg class="yf-icon" aria-hidden="true"><use href="icons/yf-icons.svg#caret-left"/></svg></button>
    <button class="yf-button yf-button--icon" type="button" aria-label="Next"><svg class="yf-icon" aria-hidden="true"><use href="icons/yf-icons.svg#caret-right"/></svg></button>
  </div>
  <button class="yf-link" type="button">A button that reads as a link</button>
  <a class="yf-link" href="#actions">An anchor link</a>
</div>

Inputs

Every control has a visible label. Help sits under the control. When a value is wrong, set aria-invalid="true" and show a .yf-error beside the help, tied to the control with aria-describedby. The error starts with a "!", so it never relies on colour.

text fields

Shown on reports you share.

Enter an address like name@example.com.

html
<div class="yf-grid">
  <div class="yf-field">
    <label for="in-name">Name</label>
    <input class="yf-input" id="in-name" type="text" placeholder="Yoan Faure" autocomplete="off" aria-describedby="in-name-help">
    <p class="yf-help" id="in-name-help">Shown on reports you share.</p>
  </div>
  <div class="yf-field">
    <label for="in-email">Email</label>
    <input class="yf-input" id="in-email" type="email" value="not-an-email" aria-invalid="true" aria-describedby="in-email-error">
    <p class="yf-error" id="in-email-error">Enter an address like name@example.com.</p>
  </div>
  <div class="yf-field">
    <label for="in-locked">Disabled</label>
    <input class="yf-input" id="in-locked" type="text" value="Cannot edit" disabled>
  </div>
  <div class="yf-field">
    <label for="in-ro">Read only</label>
    <input class="yf-input" id="in-ro" type="text" value="a1b2c3d4e5f6" readonly>
  </div>
  <div class="yf-field">
    <label for="in-region">Region</label>
    <select class="yf-select" id="in-region">
      <option>Europe (Paris)</option>
      <option>US East (Virginia)</option>
      <option>Asia Pacific (Tokyo)</option>
    </select>
  </div>
  <div class="yf-field">
    <label for="in-notes">Notes</label>
    <textarea class="yf-textarea" id="in-notes" placeholder="What did you find?"></textarea>
  </div>
</div>

choices

Notify me by
Frequency
Preferences
html
<div class="yf-grid">
  <fieldset class="yf-fieldset">
    <legend>Notify me by</legend>
    <label class="yf-checkbox"><input type="checkbox" checked><span>Email</span></label>
    <label class="yf-checkbox"><input type="checkbox"><span>Slack</span></label>
    <label class="yf-checkbox"><input type="checkbox" id="in-mixed"><span>Everything (mixed)</span></label>
    <label class="yf-checkbox"><input type="checkbox" disabled><span>SMS (disabled)</span></label>
  </fieldset>
  <fieldset class="yf-fieldset">
    <legend>Frequency</legend>
    <label class="yf-radio"><input type="radio" name="in-freq" checked><span>Daily</span></label>
    <label class="yf-radio"><input type="radio" name="in-freq"><span>Weekly</span></label>
    <label class="yf-radio"><input type="radio" name="in-freq" disabled><span>Never (disabled)</span></label>
  </fieldset>
  <fieldset class="yf-fieldset">
    <legend>Preferences</legend>
    <label class="yf-switch"><input type="checkbox" role="switch" checked><span>Weekly digest</span></label>
    <label class="yf-switch"><input type="checkbox" role="switch"><span>Product news</span></label>
    <label class="yf-switch"><input type="checkbox" role="switch" disabled><span>Disabled</span></label>
  </fieldset>
</div>
js
document.getElementById('in-mixed').indeterminate = true;   // "mixed" can only be set from script

inline fields

.yf-field--inline puts the label beside the control, and stacks under 36rem. .yf-fieldset--inline lays options in a row.

Visible to your team.

Severity

Choose a severity.

html
<div class="yf-stack">
  <div class="yf-field yf-field--inline">
    <label for="in-display">Display name</label>
    <input class="yf-input" id="in-display" type="text" value="Yoan">
    <p class="yf-help">Visible to your team.</p>
  </div>
  <fieldset class="yf-fieldset yf-fieldset--inline" aria-describedby="in-sev-error">
    <legend>Severity</legend>
    <label class="yf-radio"><input type="radio" name="in-sev" aria-invalid="true"><span>Low</span></label>
    <label class="yf-radio"><input type="radio" name="in-sev" aria-invalid="true"><span>Medium</span></label>
    <label class="yf-radio"><input type="radio" name="in-sev" aria-invalid="true"><span>High</span></label>
    <p class="yf-error" id="in-sev-error">Choose a severity.</p>
  </fieldset>
</div>

Feedback

A toast confirms something that already happened, then goes away. A banner stays until the situation changes. A tooltip adds a hint to a control that already has a name; it never carries the only copy of anything. Status colour shows on the mark and the rule only; the words stay ink.

banners

Heads up

The neutral note is ink. A link inside still inverts.

Scans run every night at 02:00 UTC.

Report sent

Four people can now open it.

Quota almost used

You have used 92% of this month's scans.

html
<div class="yf-stack yf-stack--sm">
  <div class="yf-banner" role="status">
    <span class="yf-banner__icon" aria-hidden="true"></span>
    <div class="yf-banner__body">
      <p class="yf-banner__title">Heads up</p>
      <p>The neutral note is ink. <a href="#feedback">A link inside</a> still inverts.</p>
    </div>
  </div>
  <div class="yf-banner yf-banner--info" role="status">
    <span class="yf-banner__icon" aria-hidden="true"></span>
    <div class="yf-banner__body">
      <p>Scans run every night at 02:00 UTC.</p>
    </div>
  </div>
  <div class="yf-banner yf-banner--success" role="status">
    <span class="yf-banner__icon" aria-hidden="true"></span>
    <div class="yf-banner__body">
      <p class="yf-banner__title">Report sent</p>
      <p>Four people can now open it.</p>
    </div>
  </div>
  <div class="yf-banner yf-banner--warning" role="status">
    <span class="yf-banner__icon" aria-hidden="true"></span>
    <div class="yf-banner__body">
      <p class="yf-banner__title">Quota almost used</p>
      <p>You have used 92% of this month's scans.</p>
    </div>
  </div>
  <div class="yf-banner yf-banner--danger" role="alert">
    <span class="yf-banner__icon" aria-hidden="true"></span>
    <div class="yf-banner__body">
      <p class="yf-banner__title">Scan failed</p>
      <p>The repository could not be reached. Check the access token and try again.</p>
    </div>
  </div>
</div>

Keep the mark's span empty and it draws its own glyph, so it needs no sprite. Use role="alert" for a danger banner that appears after the page loads and role="status" for the others.

toasts

Call yf.toast(). The region is created when the page loads, because a live region has to exist before it is filled. While a modal dialog is open the page behind it is inert, so toasts go to a region inside the dialog, above it and still clickable. A danger toast is role="alert". At most five show. The clock stops while the pointer or focus is on a toast.

js
yf.toast('Copied to the clipboard.');
yf.toast({
  message: 'Report saved.',
  status: 'success',                        // 'info', 'success', 'warning' or 'danger'
  action: { label: 'Undo', onClick: () => restore() },
  timeout: 8000,                            // ms; 0 keeps it until dismissed
});
yf.toast.dismissAll();

The markup, for a toast you render yourself or want to see at rest:

Copied to the clipboard.

Report saved.

Could not delete the report because it is shared with 3 teams.

html
<div class="yf-grid yf-grid--lg">
  <div class="yf-toast"><div class="yf-toast__body"><p class="yf-toast__message">Copied to the clipboard.</p></div><button class="yf-button yf-button--ghost yf-button--close yf-toast__dismiss" type="button" aria-label="Dismiss"></button></div>
  <div class="yf-toast yf-toast--success"><span class="yf-toast__icon" aria-hidden="true"></span><div class="yf-toast__body"><p class="yf-toast__message">Report saved.</p></div><button class="yf-link yf-toast__action" type="button">Undo</button><button class="yf-button yf-button--ghost yf-button--close yf-toast__dismiss" type="button" aria-label="Dismiss"></button></div>
  <div class="yf-toast yf-toast--danger"><span class="yf-toast__icon" aria-hidden="true"></span><div class="yf-toast__body"><p class="yf-toast__message">Could not delete the report because it is shared with 3 teams.</p></div><button class="yf-button yf-button--ghost yf-button--close yf-toast__dismiss" type="button" aria-label="Dismiss"></button></div>
</div>

tooltips

CSS only; the tip shows on hover and on keyboard focus. yf.js adds Escape to dismiss it and exposes it as the description. Placement is data-yf-tooltip-pos: top (the default), bottom, left or right. An input or an svg cannot have a tip of its own; wrap it.

html
<div class="yf-cluster yf-cluster--lg">
  <button class="yf-button" type="button" data-yf-tooltip="Appears above">Top</button>
  <button class="yf-button" type="button" data-yf-tooltip="Appears below" data-yf-tooltip-pos="bottom">Bottom</button>
  <button class="yf-button" type="button" data-yf-tooltip="Appears on the left" data-yf-tooltip-pos="left">Left</button>
  <button class="yf-button" type="button" data-yf-tooltip="Appears on the right" data-yf-tooltip-pos="right">Right</button>
</div>

Overlays

A dialog stops the page for a decision that cannot wait. A menu holds the actions for one thing. Tabs split one view into peers. Dialog and menu are the platform's own <dialog> and popover, so Escape, focus return and the top layer come free. The three overlays (these two and the toast) share one look: paper, a 1px ink edge and the overlay shadow.

dialog

Share this report

Anyone with the link can open it. They cannot edit.

Delete this report?

This removes the report and its 14 findings for everyone. You cannot undo it.

html
<div class="yf-cluster">
  <button class="yf-button" type="button" data-yf-dialog-open="dlg-share" command="show-modal" commandfor="dlg-share">Share…</button>
  <button class="yf-button yf-button--danger" type="button" data-yf-dialog-open="dlg-delete" command="show-modal" commandfor="dlg-delete">Delete…</button>
</div>

<dialog class="yf-dialog" id="dlg-share" aria-labelledby="dlg-share-title">
  <div class="yf-dialog__header">
    <h2 class="yf-dialog__title" id="dlg-share-title">Share this report</h2>
    <button class="yf-button yf-button--ghost yf-button--close" type="button" data-yf-dialog-close aria-label="Close"></button>
  </div>
  <div class="yf-dialog__body yf-stack">
    <p>Anyone with the link can open it. They cannot edit.</p>
    <div class="yf-field">
      <label for="dlg-share-email">Email</label>
      <input class="yf-input" id="dlg-share-email" type="email" placeholder="name@example.com">
    </div>
  </div>
  <form class="yf-dialog__footer" method="dialog">
    <button class="yf-button" value="cancel">Cancel</button>
    <button class="yf-button yf-button--primary" value="share">Share</button>
  </form>
</dialog>

<dialog class="yf-dialog" id="dlg-delete" role="alertdialog" aria-labelledby="dlg-delete-title" aria-describedby="dlg-delete-text">
  <div class="yf-dialog__header">
    <h2 class="yf-dialog__title" id="dlg-delete-title">Delete this report?</h2>
    <button class="yf-button yf-button--ghost yf-button--close" type="button" data-yf-dialog-close aria-label="Close"></button>
  </div>
  <div class="yf-dialog__body">
    <p id="dlg-delete-text">This removes the report and its 14 findings for everyone. You cannot undo it.</p>
  </div>
  <div class="yf-dialog__footer">
    <button class="yf-button" type="button" data-yf-dialog-close autofocus>Keep report</button>
    <button class="yf-button yf-button--danger" type="button" data-yf-dialog-close>Delete report</button>
  </div>
</dialog>

A click on the backdrop closes a dialog; data-yf-dialog-static turns that off. In a destructive dialog, put autofocus on the safe button. --yf-dialog-width sets the width and .yf-dialog--wide is 44rem. From script: yf.dialog.open('dlg-share'). The dialog's returnValue is the value of the button that closed it.

menu

html
<div class="yf-cluster">
  <button class="yf-button" type="button" popovertarget="menu-actions" id="menu-actions-trigger">Actions<svg class="yf-icon" aria-hidden="true"><use href="icons/yf-icons.svg#caret-down"/></svg></button>
  <div class="yf-menu" id="menu-actions" popover aria-labelledby="menu-actions-trigger">
    <p class="yf-menu__label">Report</p>
    <button class="yf-menu__item" type="button"><svg class="yf-icon" aria-hidden="true"><use href="icons/yf-icons.svg#pencil-simple"/></svg>Rename<span class="yf-menu__shortcut">R</span></button>
    <button class="yf-menu__item" type="button"><svg class="yf-icon" aria-hidden="true"><use href="icons/yf-icons.svg#copy"/></svg>Duplicate<span class="yf-menu__shortcut">⌘D</span></button>
    <a class="yf-menu__item" href="#overlays"><svg class="yf-icon" aria-hidden="true"><use href="icons/yf-icons.svg#arrow-square-out"/></svg>Open in new tab</a>
    <button class="yf-menu__item" type="button" disabled><svg class="yf-icon" aria-hidden="true"><use href="icons/yf-icons.svg#download-simple"/></svg>Export (disabled)</button>
    <div class="yf-menu__separator"></div>
    <button class="yf-menu__item yf-menu__item--danger" type="button"><svg class="yf-icon" aria-hidden="true"><use href="icons/yf-icons.svg#trash"/></svg>Delete</button>
  </div>
</div>

Without yf.js the menu still opens and closes and its items are buttons. With it the menu gets role="menu", arrow keys, Home and End, type-ahead and Tab to close. It closes after a choice, unless the item has data-yf-keep-open. Placement uses CSS anchor positioning, and the script places it where that is missing.

tabs

The summary. Arrow keys move between tabs; Home and End jump to the ends.

html
<div class="yf-tabs" data-yf-tabs>
  <div role="tablist" aria-label="Report sections">
    <button role="tab" id="tab-summary" aria-controls="panel-summary" aria-selected="true" type="button">Summary</button>
    <button role="tab" id="tab-findings" aria-controls="panel-findings" aria-selected="false" tabindex="-1" type="button">Findings <span class="yf-count">14</span></button>
    <button role="tab" id="tab-history" aria-controls="panel-history" aria-selected="false" tabindex="-1" type="button">History <span class="yf-count">31</span></button>
    <button role="tab" id="tab-settings" aria-controls="panel-settings" aria-selected="false" tabindex="-1" type="button" disabled>Settings</button>
  </div>
  <div role="tabpanel" id="panel-summary" aria-labelledby="tab-summary" tabindex="0"><p>The summary. Arrow keys move between tabs; Home and End jump to the ends.</p></div>
  <div role="tabpanel" id="panel-findings" aria-labelledby="tab-findings" tabindex="0" hidden><p>Fourteen findings, newest first.</p></div>
  <div role="tabpanel" id="panel-history" aria-labelledby="tab-history" tabindex="0" hidden><p>Every scan since March.</p></div>
  <div role="tabpanel" id="panel-settings" aria-labelledby="tab-settings" tabindex="0" hidden><p>Never shown: the tab is disabled.</p></div>
</div>

Tabs need yf.js to switch. A .yf-count after a label takes the tab's colour. The tab named by the URL hash is selected on load. When the list is wider than its box it scrolls, its far edge fades, and the selected tab scrolls into view. Filter tabs can all point at one panel (the same aria-controls): it stays shown, and the page filters it on yf:tabchange.

Data

Tables hold records and stats hold one number each. Numbers are right-aligned and tabular, ids are mono, and a status is always text plus a form.

table

Ruled, no boxes. Click a header to sort: ascending, descending, then the original order; data-yf-sort-first="descending" on a header starts that column high to low. Numbers sort as numbers and text sorts naturally ("item 2" before "item 10"). Without yf.js the headers are plain. A text cell keeps 7rem so it never squeezes under a word or two; a cell with any data-type (yours too, such as pill or actions) sizes to its content, and --yf-cell-min on a column's cells changes the minimum. .yf-wide on the cells of the column that carries the sentence (a title) keeps 14rem. The .yf-table__empty row shows while no other row is visible, rows a filter has hidden included. A sticky-header wrap is 28rem tall; .yf-table-wrap--tall makes it 40rem. Put a wide table in a .yf-table-wrap that scrolls sideways, with tabindex="0" so the keyboard can scroll it.

6 findings, last run 12:04 UTC
Check Asset Age (days) Status
Public S3 buckets3://billing-exports112Critical
MFA not enforcedokta/contractors9Warning
TLS 1.0 disabledapi.example.com31Passed
Key older than 90 daysgh/deploy-bot100Ignored
Open security groupsg-0a91c35Critical
Unused IAM rolerole/legacy-etl240Warning
html
<div class="yf-table-wrap" tabindex="0" role="region" aria-label="Findings">
  <table class="yf-table" data-yf-sortable>
    <caption>6 findings, last run 12:04 UTC</caption>
    <thead>
      <tr>
        <th scope="col" aria-sort="none">Check</th>
        <th scope="col" aria-sort="none">Asset</th>
        <th scope="col" aria-sort="none" class="yf-num">Age (days)</th>
        <th scope="col" aria-sort="none">Status</th>
      </tr>
    </thead>
    <tbody>
      <tr><td>Public S3 bucket</td><td class="yf-mono">s3://billing-exports</td><td class="yf-num">112</td><td><span class="yf-status yf-status--danger">Critical</span></td></tr>
      <tr><td>MFA not enforced</td><td class="yf-mono">okta/contractors</td><td class="yf-num">9</td><td><span class="yf-status yf-status--warning">Warning</span></td></tr>
      <tr aria-selected="true"><td>TLS 1.0 disabled</td><td class="yf-mono">api.example.com</td><td class="yf-num">31</td><td><span class="yf-status yf-status--success">Passed</span></td></tr>
      <tr><td>Key older than 90 days</td><td class="yf-mono">gh/deploy-bot</td><td class="yf-num">100</td><td><span class="yf-status yf-status--neutral">Ignored</span></td></tr>
      <tr><td>Open security group</td><td class="yf-mono">sg-0a91c3</td><td class="yf-num">5</td><td><span class="yf-status yf-status--danger">Critical</span></td></tr>
      <tr><td>Unused IAM role</td><td class="yf-mono">role/legacy-etl</td><td class="yf-num">240</td><td><span class="yf-status yf-status--warning">Warning</span></td></tr>
    </tbody>
  </table>
</div>

A selected row is aria-selected="true" (or .is-selected). .yf-table--sticky keeps the header in view in a scrolling wrap (--yf-table-max-h sets the height). Sort by something other than the text with data-sort-value. An empty table shows one row:

CheckAssetStatus
No findings match this filter.
html
<div class="yf-table-wrap">
  <table class="yf-table">
    <thead><tr><th scope="col">Check</th><th scope="col">Asset</th><th scope="col">Status</th></tr></thead>
    <tbody><tr class="yf-table__empty"><td colspan="3">No findings match this filter.</td></tr></tbody>
  </table>
</div>

status

A pill with a dot and always a text label. The plain pill is neutral: an outline with a hollow dot. An icon as the first child replaces the dot.

Critical Warning Passed Ignored Default Critical Passed Queued
html
<div class="yf-cluster">
  <span class="yf-status yf-status--danger">Critical</span>
  <span class="yf-status yf-status--warning">Warning</span>
  <span class="yf-status yf-status--success">Passed</span>
  <span class="yf-status yf-status--neutral">Ignored</span>
  <span class="yf-status">Default</span>
  <span class="yf-status yf-status--danger"><svg class="yf-icon" aria-hidden="true"><use href="icons/yf-icons.svg#warning-circle"/></svg>Critical</span>
  <span class="yf-status yf-status--success"><svg class="yf-icon" aria-hidden="true"><use href="icons/yf-icons.svg#check-circle"/></svg>Passed</span>
  <span class="yf-status yf-status--neutral"><svg class="yf-icon" aria-hidden="true"><use href="icons/yf-icons.svg#clock"/></svg>Queued</span>
</div>

stats and sparklines

A row of stats with 1px rules between them, wrapping with the rules intact. On a phone the row stays one line and scrolls sideways, so a last stat is never left alone on its line. The delta text carries the meaning; the arrow and the colour are extra. A sparkline draws from its attribute: data-yf-threshold=">40" turns the last point danger above 40 and draws a dashed limit, and "<95" does the same below 95. Without yf.js the host stays empty, so keep the number in the stat.

Open findings
42
+6 since Monday
Critical
3
1 fewer than last week
Coverage
94.2%
-1.3 points, below target
Median time to fix
3.4days
-0.6 days
html
<dl class="yf-stats-row">
  <div class="yf-stat">
    <dt class="yf-stat__label">Open findings</dt>
    <dd class="yf-stat__value">42</dd>
    <dd class="yf-stat__delta yf-stat__delta--up yf-stat__delta--danger">+6 since Monday</dd>
    <dd><span class="yf-sparkline" data-yf-sparkline="30,32,31,35,34,38,42" data-yf-threshold=">40"></span></dd>
  </div>
  <div class="yf-stat">
    <dt class="yf-stat__label">Critical</dt>
    <dd class="yf-stat__value">3</dd>
    <dd class="yf-stat__delta yf-stat__delta--down yf-stat__delta--success">1 fewer than last week</dd>
    <dd><span class="yf-sparkline" data-yf-sparkline="5,5,4,4,4,3,3"></span></dd>
  </div>
  <div class="yf-stat">
    <dt class="yf-stat__label">Coverage</dt>
    <dd class="yf-stat__value">94.2<span class="yf-stat__unit">%</span></dd>
    <dd class="yf-stat__delta yf-stat__delta--down yf-stat__delta--warning">-1.3 points, below target</dd>
    <dd><span class="yf-sparkline" data-yf-sparkline="97,96.5,96,95.5,95.1,94.8,94.2" data-yf-threshold="<95"></span></dd>
  </div>
  <div class="yf-stat">
    <dt class="yf-stat__label">Median time to fix</dt>
    <dd class="yf-stat__value">3.4<span class="yf-stat__unit">days</span></dd>
    <dd class="yf-stat__delta yf-stat__delta--down">-0.6 days</dd>
  </div>
</dl>

key-value

One value per key. It stacks under 36rem; .yf-kv--stack stacks it at any width, for a narrow side panel.

Asset
s3://billing-exports
Region
eu-west-1 (Ireland)
Status
Critical
First seen
, 112 days ago
Fingerprint
sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
html
<dl class="yf-kv">
  <div><dt>Asset</dt><dd class="yf-mono">s3://billing-exports</dd></div>
  <div><dt>Region</dt><dd>eu-west-1 (Ireland)</dd></div>
  <div><dt>Status</dt><dd><span class="yf-status yf-status--danger">Critical</span></dd></div>
  <div><dt>First seen</dt><dd><time datetime="2026-07-09">9 Jul 2026</time>, 112 days ago</dd></div>
  <div><dt>Fingerprint</dt><dd class="yf-mono">sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08</dd></div>
</dl>

progress and meter

Native elements. Progress is an ink bar on a rule track; leave out value for indeterminate. A meter maps its low, high and optimum to the status colours, so show the number beside it. The healthy range stays ink; only low and high take a status colour.

html
<div class="yf-grid">
  <div class="yf-stack">
    <progress class="yf-progress" value="40" max="100" aria-label="Scan progress, 40 percent"></progress>
    <progress class="yf-progress" aria-label="Working"></progress>
  </div>
  <div class="yf-stack">
    <meter class="yf-meter" min="0" max="100" low="60" high="85" optimum="0" value="30" aria-label="Disk, 30 percent"></meter>
    <meter class="yf-meter" min="0" max="100" low="60" high="85" optimum="0" value="72" aria-label="Disk, 72 percent"></meter>
    <meter class="yf-meter" min="0" max="100" low="60" high="85" optimum="0" value="94" aria-label="Disk, 94 percent"></meter>
  </div>
</div>

code

The block this page uses for its snippets. data-yf-copy copies the nearest block; data-yf-copy="#id" copies that element (an input's value, else its text). The label changes to the word in data-yf-copied for a moment. Where the clipboard is unavailable it selects the text and says "Selected". Without a head, put the button straight inside .yf-code and it floats top right. Give a scrolling pre tabindex="0".

deploy.sh
#!/bin/sh
set -eu
aws s3 sync ./dist s3://example/v0.1.0/
echo "deployed v0.1.0"
curl -s https://api.example.com/v1/findings \
  -H "Authorization: Bearer $TOKEN"
html
<div class="yf-grid yf-grid--lg">
  <div class="yf-code">
    <div class="yf-code__head">
      <span class="yf-code__file">deploy.sh</span>
      <button class="yf-code__copy" type="button" data-yf-copy>
        <svg class="yf-icon yf-icon--sm" aria-hidden="true"><use href="icons/yf-icons.svg#copy"/></svg>
        <span data-yf-copy-label>Copy</span>
      </button>
    </div>
<pre tabindex="0"><code>#!/bin/sh
set -eu
aws s3 sync ./dist s3://example/v0.1.0/
echo "deployed v0.1.0"</code></pre>
  </div>
  <div class="yf-code">
    <button class="yf-code__copy" type="button" data-yf-copy aria-label="Copy code">
      <svg class="yf-icon yf-icon--sm" aria-hidden="true"><use href="icons/yf-icons.svg#copy"/></svg>
    </button>
<pre tabindex="0"><code>curl -s https://api.example.com/v1/findings \
  -H "Authorization: Bearer $TOKEN"</code></pre>
  </div>
</div>

Charts

Series are ink shades, and each shade has a dash pattern, so a chart reads in grey, in print and for colour-blind readers. Never rely on the shade alone, and never use colour for a series. Status colour marks a threshold only: a dashed danger line is a limit and nothing else, and a bar over it is labelled with its number.

Previewtokendashlightdark
--yf-series-1none#262523 13.3:1#ece6d9 13.5:1
--yf-series-26 3#53514d 6.9:1#bfbaaf 8.7:1
--yf-series-31.5 3#74726c 4.1:1#9e9a91 6.0:1
--yf-series-48 3 1.5 3#898680 3.1:1#89867e 4.6:1

Every shade stays above 3:1 against paper in both themes. The shades sit close together on purpose, so the dash tells series apart and a direct label or a legend key names them.

Charts are hand-made or script-made SVG. .yf-chart sets the type; .yf-chart-grid and .yf-chart-axis draw the frame; .yf-series-1 to -4 give a line its shade and dash and a shape its fill; .yf-chart-threshold draws a limit. Set stroke-width as an attribute. Give every chart a text alternative: an aria-label that says what it shows, and the numbers somewhere a reader can reach.

Open findings by environment
0 25 50 jan apr jul oct limit 40 prod 47
  • prod
  • staging
  • dev
  • sandbox
  • limit
Days to fix, last 7 criticals
limit 7 days 8 11
html
<div class="yf-grid">
  <figure>
    <figcaption class="yf-stat__label">Open findings by environment</figcaption>
    <svg class="yf-chart" viewBox="0 0 340 150" role="img" aria-label="Line chart: open findings by environment over twelve months. Prod rises from 12 to 47 and crosses the limit of 40 in October.">
      <line class="yf-chart-grid" x1="22" x2="340" y1="128" y2="128"/>
      <text x="0" y="131">0</text>
      <line class="yf-chart-grid" x1="22" x2="340" y1="75" y2="75"/>
      <text x="0" y="78">25</text>
      <line class="yf-chart-grid" x1="22" x2="340" y1="22" y2="22"/>
      <text x="0" y="25">50</text>
      <text x="22" y="144">jan</text>
      <text x="107.6" y="144">apr</text>
      <text x="193.3" y="144">jul</text>
      <text x="278.9" y="144">oct</text>
      <line class="yf-chart-threshold" x1="22" x2="340" y1="43.2" y2="43.2"/>
      <text class="yf-chart-threshold" x="24" y="39.2">limit 40</text>
      <polyline class="yf-series-4" stroke-width="1.75" points="22,123.8 50.5,121.6 79.1,121.6 107.6,119.5 136.2,121.6 164.7,117.4 193.3,115.3 221.8,117.4 250.4,113.2 278.9,115.3 307.5,111 336,108.9"/>
      <polyline class="yf-series-3" stroke-width="1.75" points="22,119.5 50.5,115.3 79.1,117.4 107.6,108.9 136.2,111 164.7,104.7 193.3,100.4 221.8,102.6 250.4,98.3 278.9,100.4 307.5,92 336,87.7"/>
      <polyline class="yf-series-2" stroke-width="1.75" points="22,111 50.5,106.8 79.1,98.3 107.6,102.6 136.2,94.1 164.7,87.7 193.3,89.8 221.8,81.4 250.4,83.5 278.9,75 307.5,70.8 336,72.9"/>
      <polyline class="yf-series-1" stroke-width="2.25" points="22,102.6 50.5,89.8 79.1,96.2 107.6,81.4 136.2,64.4 164.7,72.9 193.3,55.9 221.8,62.3 250.4,47.4 278.9,39 307.5,43.2 336,28.4"/>
      <circle class="yf-series-1" cx="336" cy="28.4" r="3"/>
      <text class="yf-chart-label" x="292" y="20.4">prod 47</text>
    </svg>
    <ul class="yf-legend">
      <li><svg class="yf-legend__key" viewBox="0 0 24 8" aria-hidden="true"><line class="yf-series-1" x1="0" y1="4" x2="24" y2="4"/></svg>prod</li>
      <li><svg class="yf-legend__key" viewBox="0 0 24 8" aria-hidden="true"><line class="yf-series-2" x1="0" y1="4" x2="24" y2="4"/></svg>staging</li>
      <li><svg class="yf-legend__key" viewBox="0 0 24 8" aria-hidden="true"><line class="yf-series-3" x1="0" y1="4" x2="24" y2="4"/></svg>dev</li>
      <li><svg class="yf-legend__key" viewBox="0 0 24 8" aria-hidden="true"><line class="yf-series-4" x1="0" y1="4" x2="24" y2="4"/></svg>sandbox</li>
      <li><svg class="yf-legend__key" viewBox="0 0 24 8" aria-hidden="true"><line class="yf-chart-threshold" x1="0" y1="4" x2="24" y2="4"/></svg>limit</li>
    </ul>
  </figure>
  <figure>
    <figcaption class="yf-stat__label">Days to fix, last 7 criticals</figcaption>
    <svg class="yf-chart" viewBox="0 0 340 150" role="img" aria-label="Bar chart: days to fix the last seven critical findings. Two took longer than the limit of 7 days: 8 and 11.">
      <rect class="yf-series-1" x="22" y="101.5" width="30" height="26.5"/>
      <rect class="yf-series-1" x="66" y="83.8" width="30" height="44.2"/>
      <rect class="yf-series-1" x="110" y="110.3" width="30" height="17.7"/>
      <rect fill="var(--yf-danger)" x="154" y="57.3" width="30" height="70.7"/>
      <rect class="yf-series-1" x="198" y="92.7" width="30" height="35.3"/>
      <rect fill="var(--yf-danger)" x="242" y="30.8" width="30" height="97.2"/>
      <rect class="yf-series-1" x="286" y="75" width="30" height="53"/>
      <line class="yf-chart-axis" x1="22" x2="330" y1="128" y2="128"/>
      <line class="yf-chart-threshold" x1="22" x2="330" y1="66.2" y2="66.2"/>
      <text class="yf-chart-threshold" x="24" y="62.2">limit 7 days</text>
      <text class="yf-chart-label" x="165" y="53.3">8</text>
      <text class="yf-chart-label" x="253" y="26.8">11</text>
    </svg>
  </figure>
</div>

The legend reuses the series classes on a short line, so its keys carry the same dash. A shape over its limit takes fill="var(--yf-danger)" in place of a series class, and its value is printed above it.

yf.chart

yf.chart draws a line or bar chart from a spec, at the width it is shown. It chooses round tick numbers, thins the x labels to fit, moves the end labels of a line apart so none overlap, writes the aria-label from your data, and redraws when its box changes size, so the text stays 10px at any width. Put the spec in a JSON block inside a block element, and set data-yf-chart to line or bar:

Open findings by environment
Open criticals by team
html
<div class="yf-grid">
  <figure data-yf-chart="line">
    <figcaption class="yf-stat__label">Open findings by environment</figcaption>
    <script type="application/json">
    {
      "title": "open findings by environment, last six weeks",
      "labels": ["1 Sep", "8 Sep", "15 Sep", "22 Sep", "29 Sep", "6 Oct"],
      "series": [
        { "name": "prod", "values": [22, 25, 31, 36, 41, 47] },
        { "name": "staging", "values": [30, 28, 29, 33, 35, 34] },
        { "name": "dev", "values": [18, 20, 19, 24, 22, 26] }
      ],
      "thresholds": [{ "value": 40, "label": "limit 40" }],
      "legend": true
    }
    </script>
  </figure>
  <figure data-yf-chart="bar">
    <figcaption class="yf-stat__label">Open criticals by team</figcaption>
    <script type="application/json">
    {
      "title": "open critical findings by team",
      "bars": [
        { "label": "platform", "value": 2 },
        { "label": "payments", "value": 4 },
        { "label": "identity", "value": 3 },
        { "label": "data", "value": 7 },
        { "label": "web", "value": 1 }
      ],
      "values": true,
      "thresholds": [{ "value": 5, "label": "limit 5" }]
    }
    </script>
  </figure>
</div>

The chart is drawn where the script block sits; the caption and anything else in the figure are yours. A bar past a threshold turns danger and always prints its number, as in the hand-made chart above. A chart that is hidden when the page loads, in a closed <details> or an inactive tab, draws when it gets a width. With no data it draws an empty frame.

From script, call yf.chart.line(el, spec) or yf.chart.bar(el, spec). Each returns { update(spec), destroy() }. update merges the keys you pass into the spec and redraws.

js
const chart = yf.chart.line(document.getElementById('trend'), {
  title: 'open findings by severity',
  labels: ['3 Aug', '10 Aug', '17 Aug'],
  series: [
    { name: 'Critical', values: [12, 9, 7] },
    { name: 'High', values: [30, 31, 24] },
  ],
  thresholds: [{ value: 10, label: 'target 10' }],
});

chart.update({ series: newSeries }); // merge into the spec, redraw
chart.destroy(); // stop watching, remove the svg
keyonwhat it does
labelslineOne x label per point. They thin out to fit, and the last one stays.
serieslineUp to four { name, values }, drawn as .yf-series-1 to -4. A value that is not a number leaves a gap.
barsbar{ label, value, note } for each bar. A null value reads "none"; a note is a second line under the label.
valuesbartrue prints every bar's value.
thresholdsboth{ value, label, below } for each limit. A bar above it turns danger, or below it with below: true.
title, labelbothtitle says what the chart shows and starts the aria-label. It is not drawn. label replaces the whole aria-label.
legendbothtrue draws a .yf-legend under the chart: each series with its shade and dash, then each threshold by its label. A bar chart's legend lists its thresholds.
min, max, decimalsbothThe value range (default: zero up to the next round number past the data) and the decimals in value labels.
height, marginbothPixels. margin is { top, right, bottom, left }; the defaults leave room for the tick labels and the end labels.
empty, nonebothThe text for a chart with no data ("No data") and for a bar with no value ("none").

Content

.yf-prose is the reading column from yoanfaure.com: 38rem, about 68 characters of Inter, with spacing for headings, lists, quotes, tables and figures. Use it for long text, not for app chrome. The headings in these examples are demo content; the h1 of a real page is yours.

prose

Controls are the only round things. A button has a 3px corner; a page, a table and a card have none.1

Ink on paper

Everything is ink on paper, with one weight of emphasis and a muted grey for the small stuff.

  • Nothing is coloured to look nice.
  • A list item can run long enough to wrap onto a second line, which shows the hanging indent.
    • Nested items get a smaller gap.

A plain quote keeps the rule and the italics. It is the quiet one.

LayerHolds
yf.resetthe reset
yf.componentsbuttons, tables, states
  1. The 3px is a token, --yf-radius. ↩

html
<article class="yf-prose">
  <ul class="yf-meta"><li>essay</li><li><time datetime="2026-10-12">12 Oct 2026</time></li><li><a href="#content">a1b2c3d</a></li></ul>
  <p>Controls are the only round things. A button has a <code>3px</code> corner; a page, a table and a card have none.<sup><a href="#fn-radius" id="ref-radius">1</a></sup></p>
  <h3>Ink on paper<a class="yf-anchor" href="#content" aria-label="Link to this section"></a></h3>
  <p>Everything is ink on paper, with <strong>one weight of emphasis</strong> and a muted grey for the small stuff.</p>
  <ul>
    <li>Nothing is coloured to look nice.</li>
    <li>A list item can run long enough to wrap onto a second line, which shows the hanging indent.
      <ul><li>Nested items get a smaller gap.</li></ul>
    </li>
  </ul>
  <blockquote><p>A plain quote keeps the rule and the italics. It is the quiet one.</p></blockquote>
  <table>
    <thead><tr><th>Layer</th><th>Holds</th></tr></thead>
    <tbody>
      <tr><td>yf.reset</td><td>the reset</td></tr>
      <tr><td>yf.components</td><td>buttons, tables, states</td></tr>
    </tbody>
  </table>
  <section class="yf-footnotes">
    <ol><li id="fn-radius"><p>The 3px is a token, <code>--yf-radius</code>. <a class="yf-footnote-back" href="#ref-radius" aria-label="Back to the text">&#8617;</a></p></li></ol>
  </section>
</article>

.yf-meta is the meta line (a list or a paragraph). .yf-anchor is a heading permalink, hidden until the heading is hovered or the link is focused; it stays reachable by keyboard.

log

The git-log list from the home of yoanfaure.com: hash, date, type, title, in columns that hold when a title wraps. .yf-log--lg is the home size.

html
<ol class="yf-log">
  <li class="yf-log__row"><a class="yf-log__hash" href="#content">a1b2c3d</a> <time class="yf-log__date" datetime="2026-10-12">2026-10-12</time> <span class="yf-log__type">essay</span> <a class="yf-log__title" href="#content">Why the layout is square</a></li>
  <li class="yf-log__row"><a class="yf-log__hash" href="#content">e4f5a6b</a> <time class="yf-log__date" datetime="2026-09-30">2026-09-30</time> <span class="yf-log__type">note</span> <a class="yf-log__title" href="#content">Tabular numbers in a dense table</a></li>
  <li class="yf-log__row"><span class="yf-log__hash">c7d8e9f</span> <time class="yf-log__date" datetime="2026-09-02">2026-09-02</time> <span class="yf-log__type">project</span> <a class="yf-log__title" href="#content">A very long title that wraps onto a second line to show how the grid keeps the columns where they are</a></li>
</ol>

list

Rows of things to pick: prompts, reports, runbooks. Every part but the title is optional. A linked title makes the whole row its hit area, and links or buttons in the meta stay clickable above it. In under 52rem of its own width a row is the title with the rest beneath it; wider, the sentence sits beside the title.

  1. 01 Assess the program you inherited

    Turn what exists today into a first 90-day plan, with the assumptions behind it.

    first 90 days
  2. 02 Threat model a new service

    A STRIDE pass over a design doc, ranked by what an attacker would try first.

    threat modelingappsec
  3. 03 Review an OAuth app's scopes

    Decide whether a third-party app needs the access it asks for.

    vendors
html
<ol class="yf-list">
  <li class="yf-list__item">
    <span class="yf-list__num">01</span>
    <a class="yf-list__title" href="#content">Assess the program you inherited</a>
    <p class="yf-list__text">Turn what exists today into a first 90-day plan, with the assumptions behind it.</p>
    <div class="yf-list__meta"><span class="yf-status">first 90 days</span></div>
  </li>
  <li class="yf-list__item">
    <span class="yf-list__num">02</span>
    <a class="yf-list__title" href="#content">Threat model a new service</a>
    <p class="yf-list__text">A STRIDE pass over a design doc, ranked by what an attacker would try first.</p>
    <div class="yf-list__meta"><span class="yf-status">threat modeling</span><span class="yf-status">appsec</span></div>
  </li>
  <li class="yf-list__item">
    <span class="yf-list__num">03</span>
    <a class="yf-list__title" href="#content">Review an OAuth app's scopes</a>
    <p class="yf-list__text">Decide whether a third-party app needs the access it asks for.</p>
    <div class="yf-list__meta"><span class="yf-status">vendors</span></div>
  </li>
</ol>

disclosure

What the prompt assumes

That you can read the cloud console, and that nobody has written a policy yet.

What you get back

A ranked list of risks, each with the evidence you would need to close it.

html
<div>
  <details class="yf-disclosure">
    <summary>What the prompt assumes</summary>
    <div class="yf-disclosure__body"><p>That you can read the cloud console, and that nobody has written a policy yet.</p></div>
  </details>
  <details class="yf-disclosure" open>
    <summary>What you get back</summary>
    <div class="yf-disclosure__body"><p>A ranked list of risks, each with the evidence you would need to close it.</p></div>
  </details>
</div>

A native <details>: it opens and closes with no script, by mouse and keyboard. Disclosures in a row share their rules.

callout, quote, figure

42
findings closed
3.4d
median time to fix
99.9%
scan uptime

Make the quiet part of the interface do the work.

Yoan Faure, Notes on ink
Figure 1. A caption is one muted sentence under the picture.
html
<div class="yf-stack yf-stack--lg">
  <dl class="yf-stats-callout">
    <div><dt>42</dt><dd>findings closed</dd></div>
    <div><dt>3.4d</dt><dd>median time to fix</dd></div>
    <div><dt>99.9%</dt><dd>scan uptime</dd></div>
  </dl>
  <figure class="yf-quote">
    <blockquote><p>Make the quiet part of the interface do the work.</p></blockquote>
    <figcaption>Yoan Faure, <cite>Notes on ink</cite></figcaption>
  </figure>
  <figure class="yf-figure yf-figure--framed">
    <svg viewBox="0 0 400 120" width="400" height="120" role="img" aria-label="A line that climbs from left to right"><path class="yf-series-1" fill="none" stroke-width="2" d="M10 100 L80 70 L150 80 L230 30 L320 50 L390 15"/></svg>
    <figcaption>Figure 1. A caption is one muted sentence under the picture.</figcaption>
  </figure>
</div>

States

The topo field, the ASCII contours from yoanfaure.com, appears in an app in three places only: empty states, loading states, and error pages. It is a watermark; text over it gets a halo of the page colour. The headline of an empty state invites; it never apologises. The field draws only while its box is on screen, pauses when the tab is hidden, shows one still frame under reduced motion, and a pointer over the box raises a gentle hill. yf.js is what draws it; without the script the box is empty and the text still reads.

empty

Run your first scan

Findings from the last run will show up here, newest first.

html
<section class="yf-empty">
  <div class="yf-topo" data-yf-field aria-hidden="true"></div>
  <div class="yf-empty__body">
    <h4 class="yf-empty__title">Run your first scan</h4>
    <p class="yf-empty__text">Findings from the last run will show up here, newest first.</p>
    <div class="yf-empty__action">
      <button class="yf-button yf-button--primary" type="button">Run scan</button>
      <button class="yf-button" type="button">Import findings</button>
    </div>
  </div>
</section>

.yf-empty--framed adds a hairline box. data-yf-seed="7" gives the same landscape every time, and data-yf-static draws one still frame.

Nothing to review

New findings will land here.

html
<section class="yf-empty yf-empty--framed">
  <div class="yf-topo" data-yf-field data-yf-seed="7" data-yf-static aria-hidden="true"></div>
  <div class="yf-empty__body">
    <h4 class="yf-empty__title">Nothing to review</h4>
    <p class="yf-empty__text">New findings will land here.</p>
  </div>
</section>

loading

Set aria-busy="false", or remove the element, when the content arrives.

Loading findings

html
<div class="yf-loading" role="status" aria-busy="true">
  <div class="yf-topo" data-yf-field aria-hidden="true"></div>
  <p class="yf-loading__label">Loading findings</p>
</div>

error page

Like the 404 of yoanfaure.com: a shell line, then a prompt that goes home. The h1 is for screen readers and the outline; the terminal is what you see. See this site's own at /404.

html
<main class="yf-error-page">
  <div class="yf-topo yf-topo--fixed" data-yf-field aria-hidden="true"></div>
  <div class="yf-error-page__body">
    <h1 class="yf-error-page__title">Page not found</h1>
    <pre class="yf-terminal">zsh: no such file or directory: /nope</pre>
    <pre class="yf-terminal yf-terminal--prompt"><a href="/">cd ~</a></pre>
  </div>
</main>