Skip to content
memoDwarves Foundation

Handbook

Memo media and styling standard

One rule set for images, figures, alt text, dark-theme safety and imported styling on Dwarves Memo, with a test for every rule and the checklist editors run before approving a post.

This page is the single rule set for images and figures on memo. It applies to every post, whichever door it came through. Contribute to memo summarizes it in chapters 2 and 5, and the contributor skills link here instead of copying it. The post rubric turns these rules, and the content rules around them, into a script and a checklist you run before review.

Each rule names who it applies to and how to test it.

Everyone
covers repo posts and guest imports.
Repo posts
are posts written in dwarvesf/brainery by members and fellows, through a branch or a fork.
Guest imports
are posts memo converts from a writer's own site.

A rule that says target is a budget the editors check by hand. No automatic check blocks a post on a target today.

Formats

  1. Everyone. Draw diagrams and charts as SVG, never as a screenshot of a diagram. Test: no raster file in the post holds a diagram. From Excalidraw, export SVG with "Embed scene" turned on, so the same file reopens in Excalidraw for later edits. Test: rg -c 'excalidraw' <f>.svg prints a count above 0. Reason: without the scene, a later fix to a typo or a color means redrawing the figure.
  2. Everyone. Use a raster only for photos, screenshots and scans. Use webp at quality 80, or PNG after pngquant, for screenshots, and jpeg or webp for photos. Test: file <f> reports png, webp or jpeg.
  3. Repo posts. Reference one file per image. Do not commit a .webp twin beside a .png. Test: no two files in assets/ share a basename. Reason: the worker does not negotiate formats, so the twin is never served.
  4. Everyone. Use GIF or video only when motion carries meaning that a still cannot. Prefer a short mp4 to a GIF. Test: the post says in text what the motion shows.

Byte budgets and dimensions

  1. Repo posts, target. A single-card screenshot is about 14 KB. A dense multi-panel shot may reach 50 KB. A real terminal window capture lands at 30 to 60 KB. Past that the shot does too much, so crop it.
  2. Everyone, target. No raster over 200 KB. Test: stat -f%z <f> prints 204800 or less. Reason: the compression step caps pixels and not bytes, and it passes an oversized file through. After compression, media-sync prints a warning for any raster still over 200 KB. The warning never fails the run.
  3. Everyone. No raster over 1600 pixels on the long edge. Test: identify -format "%w x %h" <f>.
  4. Everyone, target. A full-width raster is at least 704 pixels wide and at most 1600. Reason: the reading column is about 704 pixels, so a narrower image upscales and blurs.
  5. Everyone, target. An SVG is 60 KB or less. Over 150 KB needs a written reason in the pull request. Reason: across the 99 SVGs in the vault, the median is 4,735 bytes and the 90th percentile is 55,684.
  6. Repo posts. Author each SVG at a viewBox width of 760 with no fixed width or height, and add style="width:100%;height:auto;max-width:<W>px;display:block". Test: rg '<svg[^>]*\swidth="[0-9]+"' finds nothing. Reason: a fixed-width SVG wider than the column overflows into the sidebar.
  7. Guest imports. An inline SVG inside a figure.fig keeps a viewBox and sets no pixel width. Memo owns the figure box and ignores width, margin and transform rules from the writer. Test: the figure renders inside its 60rem cap with no page-level horizontal scroll.
  8. Guest imports. Wide content scrolls inside a <div class="fk-scroll">. Test: the rest of the figure keeps the plate width.
  9. Everyone. Name files <slug>-figN-<name>.<ext> for figures and <slug>-<what>.<ext> for screenshots. Test: ls assets/ matches the pattern.

Dark-theme safety

  1. Repo posts. A standalone SVG embedded as an image carries an opaque background rect sized to its viewBox. Test: the first drawing element is a rect that covers the viewBox. Reason: a transparent SVG floats on the dark theme.
  2. Repo posts. A standalone SVG uses no prefers-color-scheme block. Reason, not yet tested: an SVG loaded through <img> follows the operating system setting and not memo's theme toggle, so it can disagree with the page.
  3. Guest imports. A figure.fig colors everything through --fk-* variables or currentColor. Test: rg 'fill="#|stroke="#' inside the figure finds nothing, or each hit is a deliberate data color that reads on both themes.
  4. Guest imports. A prefers-color-scheme block in figure CSS is allowed. Memo maps it to its own toggle.
  5. Everyone, target. A raster screenshot has an opaque background. Do not commit a transparent PNG of a UI. Test: identify -format %A <f> prints False, or the image is a deliberate cutout checked on both themes.
  6. Everyone. No device chrome, browser frame or cursor in a screenshot. Test: visual check.

Alt text, captions and the hero image

  1. Everyone, target. Every image embed has non-empty alt text that says what the figure shows. Test: rg '!\[\]\(' finds nothing in the post. Today 383 files in brainery contain an empty-alt image, so this is a standard to move toward.
  2. Repo posts. Give each figure a caption in its image title, with the head-figure form ![Title {#fig-id}](assets/x.svg 'Short title. Caption sentence.'). The first sentence of the caption is the short title. Add no separate _Fig. N_ line, and refer to the figure as @fig-id. Test: post-check.mjs prints no fig-embed, fig-title or fig-line warning.
  3. Guest imports. Use figure.fig with a figcaption, or the figure head block, for the caption. A plain <figure><img> loses its structure, and the caption becomes a plain paragraph. Test: the figure appears as one block on the live page.
  4. Guest imports. An inline SVG figure has role="img" plus an aria-label, or a <title> and <desc>. Every data-tip states a fact already in the text and adds no new number.
  5. Everyone. Use one hero image, and make it the first image in the body. It must still read at thumbnail size. Test: the first embed in the body is the intended hero.
  6. Repo posts. Set image: only when the hero is not the first image. Use an absolute site path such as /content/<vault-path>/assets/<file>.png. Test: curl -s https://memo.d.foundation/<slug> | grep og:image shows that path and not home_cover.webp.
  7. Everyone, target. A raster share image is 1200 by 630 and 300 KB or less. Never use an SVG for a third-party hotlink. Reason: Facebook, LinkedIn and X reject an SVG share image. A vault SVG is safe because the worker renders a PNG twin, and a hotlinked SVG gets none.

Hotlinked versus re-hosted media

  1. Repo posts. Commit all media next to the post, under assets/. Raster files move to the storage bucket after the merge, and SVG stays in git. Test: relative-link-exists is green, and gh run list -R dwarvesf/brainery --workflow media-sync.yml --limit 1 shows success after the merge.
  2. Guest imports. At publish, memo copies each https raster image (png, jpeg, webp, gif or avif, checked by file bytes) into its own storage and serves it from memo.d.foundation/content/guest-media/. The first copied image becomes the note's share image. Nothing is copied at submit or preview. An SVG file in an <img> is not copied. It stays linked to your site, with a warning, so keep it online. Inline <svg> rich figures are unaffected. Test: after publish, the image addresses on the note page contain /content/guest-media/, each returns status 200 with a matching content type, and rg '<img[^>]*src="[^"]*\.svg' on your source page lists every image that stays linked.
  3. Guest imports. Serve every image over https from an address that stays online until the editor publishes. Memo drops http: and data: images and shows the editor a dropped_images=N warning. Test: rg '<img[^>]*src="(http:|data:)' on your source page finds nothing, and the editor preview shows no dropped_images warning.
  4. Guest imports, target. Keep each image within the raster targets in rules 6 to 8. Memo does not compress it. An image over 200 KB is still copied, with an editor warning. An image over 5 MB, or any image past 25 MB or 30 images in one post, keeps its original link, so it must stay online. Test: curl -sI <url> shows a content-length of 204800 or less, and the post stays within 25 MB and 30 images.
  5. Guest imports. Memo deletes a note's image copies when the note is taken down. After an editor re-imports an edited post, memo removes the old copies about 30 minutes later. Takedown runs once the takedown switch is on, and the guest writer report tracks that status. Test: after a takedown, a former copy address returns 404.

Third-party images and attribution

  1. Everyone. Use only images you made, own or hold a licence for. Credit any other image on the line under the caption: Image: <author>, <source link>, <licence>. Do not scrape images. Test: every image whose origin is not the author carries a credit line.
  2. Guest imports. A writer's consent covers memo storing a copy of the images in an imported post, and removing the post removes the copies. It does not cover a third-party image the writer has no licence for. Test: every image the writer did not make carries a credit line (rule 32).

Interactive figures in guest posts

The contract lives at memo.d.foundation/figures.md. These rules are the editor's side of it.

  1. Guest imports. The static figure is the figure. It must read fully with no script, in RSS and with reduced motion. Test: view the page with scripts off.
  2. Guest imports. Use figure.fig blocks only. Memo ignores inline <script> and <style> and every stylesheet except up to three same-host https sheets. Each sheet's path contains fig, or its tag carries data-figkit, and the total is at most 512 KB. Test: the figure stylesheet path matches fig.
  3. Guest imports. Use only the elements and attributes that figures.md allows. Do not use img, foreignObject, iframe, mask, textPath, SMIL, @import, @font-face, any url() other than url(#id), or position: fixed. Test: the editor preview of the converted note shows every figure intact.
  4. Guest imports. Use data-animate plus data-step for motion. Nothing plays on its own, and the Play button appears by itself. Test: no autoplay in the figure.
  5. Guest imports. Add data-fk-zoom="touch" to a dense diagram that fits a tablet plate but reads better full screen.

Animation

  1. Repo posts. Most figures get no animation. Animate only when the motion carries meaning the static frame cannot. Gate all motion behind @media (prefers-reduced-motion: no-preference), and let the base attributes show the finished figure. Test: xmllint --noout <f>.svg, then a static render shows the complete figure.
  2. Guest imports. Use figkit motion only (rule 37). Memo drops SMIL, so an SMIL animation silently becomes a static frame or nothing.

Styling memo will not carry over

  1. Guest imports. Memo loads the figure stylesheets and drops the rest of your site's styling. Plan for the cases below before you submit.
Your site does thisOn memoDo this instead
Custom web fonts (@font-face)Dropped. Fonts map to memo's sans and monoSet type with --font-body, --fk-font and --fk-mono
Page-wide CSS, theme, layoutNot loaded. Only figure stylesheets loadPut figure styles in a figures.css (the path contains fig)
Inline <script> or <style>IgnoredUse figkit attributes (data-tip, data-animate, data-fk-*)
Breakout widths, negative marginsOverridden by memo's figure boxDesign for the column and the 60rem cap
<iframe> embeds (video, tweets, sandboxes)RemovedLink out, and add a still image with alt text
<img> inside a figure.figDropped, with a dropped_images warningDraw it as inline SVG, or put the image in a plain paragraph
<picture> with srcset and sourcesOnly the fallback <img> is keptMake the fallback <img> the best single file
<svg> outside figure.figRemoved, with a dropped_figures warningWrap it in <figure class="fig">
Inline style= on proseRemovedStyle in figure CSS only, or accept memo's prose style
http: or data: imagesRemoved, with a dropped_images warningUse https image addresses
Your own share imageNot carried. The first copied image is usedPut the image you want on the card first in the post
Header, nav, footer, asideDroppedKeep the post in one <article>

House palette

  1. Repo posts by Dwarves authors, required. Use the house palette and figure header: background #12395f, box fill #21466a, box stroke #68839d, one highlight accent #e8c56a (amber in dark theme, the site accent in light), text #e8f1fa, #a7c0d9, #c3d7ea and #6f8fb0, arrow #c3d7ea, a monospace font, and a header rule at y=36. Test: the figure uses no color outside this set.
  2. Repo posts by Dwarves authors, required. One accent only, two line weights at most, labels that never crowd, and whitespace as structure. Split a busy mechanism into a short series of numbered figures.
  3. Guest imports, recommended. Follow the limits in rule 43. A guest may keep their own palette, and still meets rules 1 to 41.

Talk slides, deck and share card

A talk post (layout: talk) pairs each slide with the speaker's notes. Its media lives in one folder beside the post, assets/<slug>/, where <slug> is the note's file name. The post rubric checks rules 45 to 48 as T1 to T8, and node _meta/scripts/talk-build.mjs <note> writes files that meet them.

  1. Everyone. Slides are rasters at assets/<slug>/slides/slide-01.webp to slide-NN.webp: two digits, no gap, webp at quality 80 (png is allowed), 200 KB or less each, and a long edge of 1200 to 1600 pixels. Every slide in a deck shares one ratio, 16:9, 16:10 or 4:3, and talk.ratio in the frontmatter names it (default 16:9). Test: post-check.mjs prints no T2 or T5 warning. Reason: 1600 is the raster ceiling in rule 7, and a slide is a picture of text, so it stays a raster even when it holds a diagram. These names replace rule 13 for slides.
  2. Everyone. The deck ships as assets/<slug>/deck.pdf, one page per slide, so a reader can download and present it. Test: pdfinfo deck.pdf prints a page count equal to the slide count. media-sync moves the PDF to the bucket with the slides, and compresses it first.
  3. Everyone. The share image is assets/<slug>/og.png, a 1200 by 630 PNG of 300 KB or less rendered from slide 1, and image: points at it with an absolute /content/<vault-path>/assets/<slug>/og.png path. Test: post-check.mjs prints no T8 warning. talk-build.mjs letterboxes slide 1 in its own background color, so nothing on the slide is cropped. Rule 26 still applies.
  4. Everyone. Slide alt text says what the slide says, in 8 or more characters. Slides take no caption title and no {#fig-id}, so rule 21 does not apply to them. The notes beside the slide are its caption.
  5. Everyone. A talk is the one documented exception to the single reading measure. On a wide screen the slide and notes sit in a grid of about 1,200 pixels, slide 58 and notes 42, wider than the 704 pixel column, because a slide squeezed into the column is too small to read. Under 900 pixels wide the slide stacks on top of its notes. The intro and the outro stay at the normal measure. Memo shows every slide at its own ratio, never cropped or stretched, keeps a dark or a light slide's edge visible on both site themes, puts a transparent PNG slide on white, and letterboxes on black in Present mode. Test: open the post at 1440 and 390 pixels wide, in light and dark.
  6. Everyone, optional. Any tool's slides are welcome, in the speaker's own look. A deck from Keynote, Google Slides, Canva or PowerPoint needs no source folder; export the PDF and link the original with talk.source. For a deck that looks like memo, the house theme in _meta/talk-template/ uses the memo tokens: paper and ink neutrals, Source Serif 4, IBM Plex Sans and Plex Mono, crimson only for the dither rule and the speaker mark, and one highlighted element per diagram. talk-build.mjs --init <note> copies it into assets/<slug>/deck-src/, one HTML file per slide, which stays in git like an SVG. The fonts are added at render time and are not copied into the deck. A speaker profile as slide 2 is recommended for any speaker: --speaker <handle> fills it from _meta/talk-template/speakers/<handle>.json at every build, with the photo from the vault path in that file, never hotlinked.

Editor checklist

Run this before approving any post, repo or imported.

Checklist0 of 14 done

  • Open every image address. Status 200, correct type, not an error page. On a guest import, open the memo copy after publish.
  • Check bytes: raster 200 KB or less, SVG 60 KB or less (150 KB with a reason).
  • Check raster width: 704 to 1600 pixels, with no blurry upscale.
  • Open the page in light and dark. No transparent figure floats on the dark theme and no edge is clipped.
  • Resize to a phone width. No page-level horizontal scroll, and wide parts scroll inside their own box.
  • Every image has real alt text, and every figure has a caption.
  • Run node _meta/scripts/post-check.mjs <note> and read the post rubric result for the figure, SVG and reference rules.
  • Check the hero. The first image reads at thumbnail size, and the share card is not the site default by accident.
  • Talk post: talk-build.mjs or post-check.mjs reports T1 to T8 clean, deck.pdf opens, and the page reads at 1440 and 390 pixels wide.
  • Confirm credit for every image the author did not make, with no scraped images.
  • Guest import: read the preview warnings, including dropped_images=N, and ask the writer to fix http:, data: and <img>-in-figure cases.
  • Guest import: after publish, confirm each raster image links to content/guest-media/. Warn the writer to keep SVG files and any image over 5 MB online, because memo keeps those original links.
  • Interactive figure: view it with scripts off. The static figure must be complete.
  • Repo post: run xmllint --noout on each SVG and ./pre-commit-hook.sh install, and read the formatting bot's diff.