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.
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:
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.
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.
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.
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.
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
Swatch
token
value
on paper
--yf-bgpaper: the page
#f4efe4
paper
--yf-fgink: text, rules, the primary action
#262523
13.3:1
--yf-mutedsecondary text
#6b675f
4.9:1
--yf-ruledividers, a decoration
#ddd6c8
1.2:1
--yf-sunkpanels, code, controls
#ece6d8
1.0:1
--yf-control-borderthe edge of a control
#83807a
3.4:1
--yf-hoverhover fill (#262523 at 6%)
#e8e3d8
fill
--yf-selectedselected fill (#262523 at 11%)
#ddd9cf
fill
dark
Swatch
token
value
on paper
--yf-bgpaper: the page
#1e1d1b
paper
--yf-fgink: text, rules, the primary action
#ece6d9
13.5:1
--yf-mutedsecondary text
#9a958b
5.6:1
--yf-ruledividers, a decoration
#34322f
1.3:1
--yf-sunkpanels, code, controls
#262522
1.0:1
--yf-control-borderthe edge of a control
#8f8c84
5.0:1
--yf-hoverhover fill (#ece6d9 at 6%)
#2a2926
fill
--yf-selectedselected fill (#ece6d9 at 11%)
#353330
fill
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.
CriticalWarningPassedIgnored
light
Swatch
token
on paper
on fill
--yf-danger#a12c1b--yf-danger-bg#f1d9cf
6.3:1
5.3:1
--yf-warning#855700--yf-warning-bg#efdfba
5.4:1
4.7:1
--yf-success#3d6b2a--yf-success-bg#dbe5cc
5.4:1
4.8:1
dark
Swatch
token
on paper
on fill
--yf-danger#ec8a74--yf-danger-bg#3b2420
6.7:1
5.7:1
--yf-warning#dcab4f--yf-warning-bg#3a2f1b
8.0:1
6.2:1
--yf-success#98c27c--yf-success-bg#263221
8.2:1
6.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.
specimen
class
value
pixels
Ink on paper
.yf-text-xs--yf-text-xs
0.75rem
12px
Ink on paper
.yf-text-sm--yf-text-sm
0.875rem
14px
Ink on paper
.yf-text-base--yf-text-base
1rem
16px
Ink on paper
.yf-text-lg--yf-text-lg
1.125rem
18px
Ink on paper
.yf-text-xl--yf-text-xl
1.375rem
22px
Ink on paper
.yf-text-2xl--yf-text-2xl
1.75rem
28px
Ink on paper
.yf-text-3xl--yf-text-3xl
clamp(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.
Size
token
value
pixels
--yf-space-1
0.25rem
4px
--yf-space-2
0.5rem
8px
--yf-space-3
0.75rem
12px
--yf-space-4
1rem
16px
--yf-space-5
1.5rem
24px
--yf-space-6
2rem
32px
--yf-space-7
3rem
48px
--yf-space-8
4rem
64px
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.
.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.
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.
.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.
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.
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.
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.
Scan failed
The repository could not be reached. Check the access token and try again.
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
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.
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.
Fourteen findings, newest first.
Every scan since March.
Never shown: the tab is disabled.
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.
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:
Check
Asset
Status
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.
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.
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.
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 pretabindex="0".
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.
Preview
token
dash
light
dark
--yf-series-1
none
#262523 13.3:1
#ece6d9 13.5:1
--yf-series-2
6 3
#53514d 6.9:1
#bfbaaf 8.7:1
--yf-series-3
1.5 3
#74726c 4.1:1
#9e9a91 6.0:1
--yf-series-4
8 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.
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 environmentOpen criticals by team
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.
One x label per point. They thin out to fit, and the last one stays.
series
line
Up to four { name, values }, drawn as .yf-series-1 to -4. A value that is not a number leaves a gap.
bars
bar
{ label, value, note } for each bar. A null value reads "none"; a note is a second line under the label.
values
bar
true prints every bar's value.
thresholds
both
{ value, label, below } for each limit. A bar above it turns danger, or below it with below: true.
title, label
both
title says what the chart shows and starts the aria-label. It is not drawn. label replaces the whole aria-label.
legend
both
true 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, decimals
both
The value range (default: zero up to the next round number past the data) and the decimals in value labels.
height, margin
both
Pixels. margin is { top, right, bottom, left }; the defaults leave room for the tick labels and the end labels.
empty, none
both
The 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.
<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">↩</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.
<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.
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 inkFigure 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.
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>