Text

Prose

A wrapper that styles rendered markdown or CMS HTML inside it, at a readable measure with even rhythm.

Built on
:where()::markertext-wrap: pretty
Client JavaScript
None
Works without React
Yes, as HTML and CSS
Source, gzipped
1.7 kB
Dependencies
React

Before you upgrade

Read the changelog, then:

  • Back up the database
  • Run migrate
Code
import { Prose } from "@/components/equip/prose";

export default function Demo() {
  return (
    <Prose style={{ width: 224, fontSize: 14 }}>
      <h4>Before you upgrade</h4>
      <p>
        Read the <a href="#changelog">changelog</a>, then:
      </p>
      <ul>
        <li>Back up the database</li>
        <li>Run <code>migrate</code></li>
      </ul>
    </Prose>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="width:224px;font-size:14px" class="eq-prose"><h4>Before you upgrade</h4><p>Read the <a href="#changelog">changelog</a>, then:</p><ul><li>Back up the database</li><li>Run <code>migrate</code></li></ul></div>

Examples

An article

Plain HTML, as a markdown renderer or a CMS would hand it over: no class on any element inside.

Moving from cron jobs to scheduled functions

Cron on a single server was fine while we had one server. With six, the same job ran six times every night, and the invoice run sent some customers two emails. This is how we moved every job to scheduled functions in two weeks.

What changed

Each job is now a function with a schedule next to it in the repository. The platform runs it once, in one region, and keeps the last 30 runs with their logs. A failed run retries twice and then pages whoever is on call.

  1. List every crontab entry and who owns it.
  2. Port the jobs one at a time, starting with the ones that are safe to run twice.
  3. Leave the old entry commented out for a week before deleting it.

The hardest part was not the code. It was finding out what the 2:15 job did.

What we would do differently

Write down the expected run time for each job on day one. The first time a job took four times longer than usual, nobody noticed for a month. The duration alerts we added afterwards would have caught it the same night.

Code
import { Prose } from "@/components/equip/prose";

export default function Example() {
  return (
    <Prose>
      <h1>Moving from cron jobs to scheduled functions</h1>
      <p>
        Cron on a single server was fine while we had one server. With six, the same job ran six times every night,
        and the invoice run sent some customers two emails. This is how we moved every job to scheduled functions in
        two weeks.
      </p>
      <h2>What changed</h2>
      <p>
        Each job is now a function with a schedule next to it in the repository. The platform runs it once, in one
        region, and keeps the last 30 runs with their logs. A failed run retries twice and then pages whoever is on
        call.
      </p>
      <ol>
        <li>List every crontab entry and who owns it.</li>
        <li>
          Port the jobs one at a time, starting with the ones that are <strong>safe to run twice</strong>.
        </li>
        <li>Leave the old entry commented out for a week before deleting it.</li>
      </ol>
      <blockquote>
        <p>The hardest part was not the code. It was finding out what the 2:15 job did.</p>
      </blockquote>
      <h3>What we would do differently</h3>
      <p>
        Write down the expected run time for each job on day one. The first time a job took four times longer than
        usual, nobody noticed for a month. The <a href="#alerts">duration alerts</a> we added afterwards would have
        caught it the same night.
      </p>
    </Prose>
  );
}
HTMLworks in any framework, with the part's CSS
<div class="eq-prose"><h1>Moving from cron jobs to scheduled functions</h1><p>Cron on a single server was fine while we had one server. With six, the same job ran six times every night, and the invoice run sent some customers two emails. This is how we moved every job to scheduled functions in two weeks.</p><h2>What changed</h2><p>Each job is now a function with a schedule next to it in the repository. The platform runs it once, in one region, and keeps the last 30 runs with their logs. A failed run retries twice and then pages whoever is on call.</p><ol><li>List every crontab entry and who owns it.</li><li>Port the jobs one at a time, starting with the ones that are <strong>safe to run twice</strong>.</li><li>Leave the old entry commented out for a week before deleting it.</li></ol><blockquote><p>The hardest part was not the code. It was finding out what the 2:15 job did.</p></blockquote><h3>What we would do differently</h3><p>Write down the expected run time for each job on day one. The first time a job took four times longer than usual, nobody noticed for a month. The <a href="#alerts">duration alerts</a> we added afterwards would have caught it the same night.</p></div>

Code and tables

Long code lines scroll inside their block. In a centring flex or grid parent, give Prose a width so the block has an edge to scroll against.

Rate limits

Every response carries the X-RateLimit-Remaining header. When it reaches zero, the API answers 429 and Retry-After says how many seconds to wait.

if (res.status === 429) {
  const wait = Number(res.headers.get("Retry-After"));
  await sleep(wait * 1000);
}
PlanRequests per minuteBurst
Hobby6010
Team600100
Enterprise6,0001,000

Limits apply per API key, not per account. Ask support if a batch job needs a higher burst for a day.

Code
import { Prose } from "@/components/equip/prose";

export default function Example() {
  return (
    <Prose style={{ width: "100%" }}>
      <h2>Rate limits</h2>
      <p>
        Every response carries the <code>X-RateLimit-Remaining</code> header. When it reaches zero, the API answers
        429 and <code>Retry-After</code> says how many seconds to wait.
      </p>
      <pre>
        <code>{`if (res.status === 429) {
  const wait = Number(res.headers.get("Retry-After"));
  await sleep(wait * 1000);
}`}</code>
      </pre>
      <table>
        <thead>
          <tr>
            <th>Plan</th>
            <th>Requests per minute</th>
            <th>Burst</th>
          </tr>
        </thead>
        <tbody>
          <tr>
            <td>Hobby</td>
            <td>60</td>
            <td>10</td>
          </tr>
          <tr>
            <td>Team</td>
            <td>600</td>
            <td>100</td>
          </tr>
          <tr>
            <td>Enterprise</td>
            <td>6,000</td>
            <td>1,000</td>
          </tr>
        </tbody>
      </table>
      <hr />
      <p>
        Limits apply per API key, not per account. Ask support if a batch job needs a higher burst for a day.
      </p>
    </Prose>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="width:100%" class="eq-prose"><h2>Rate limits</h2><p>Every response carries the <code>X-RateLimit-Remaining</code> header. When it reaches zero, the API answers 429 and <code>Retry-After</code> says how many seconds to wait.</p><pre><code>if (res.status === 429) {
  const wait = Number(res.headers.get(&quot;Retry-After&quot;));
  await sleep(wait * 1000);
}</code></pre><table><thead><tr><th>Plan</th><th>Requests per minute</th><th>Burst</th></tr></thead><tbody><tr><td>Hobby</td><td>60</td><td>10</td></tr><tr><td>Team</td><td>600</td><td>100</td></tr><tr><td>Enterprise</td><td>6,000</td><td>1,000</td></tr></tbody></table><hr><p>Limits apply per API key, not per account. Ask support if a batch job needs a higher burst for a day.</p></div>

Your classes win

Prose rules are wrapped in :where(), so they weigh nothing; the inline style here and any class you add override them without extra specificity.

Changelog, 12 November

  • Branch previews now build in half the time on large monorepos.
  • Logs can be filtered by request id from the log viewer.

Shipped by the platform team. Questions go to #platform-help.

Code
import { Prose } from "@/components/equip/prose";

export default function Example() {
  return (
    <Prose style={{ maxWidth: "52ch" }}>
      <h3>Changelog, 12 November</h3>
      <ul>
        <li>Branch previews now build in half the time on large monorepos.</li>
        <li>
          Logs can be filtered by request id from the <a href="#logs">log viewer</a>.
        </li>
      </ul>
      <p style={{ color: "var(--eq-ink-3)", fontSize: "var(--eq-text-s)" }}>
        Shipped by the platform team. Questions go to #platform-help.
      </p>
    </Prose>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="max-width:52ch" class="eq-prose"><h3>Changelog, 12 November</h3><ul><li>Branch previews now build in half the time on large monorepos.</li><li>Logs can be filtered by request id from the <a href="#logs">log viewer</a>.</li></ul><p style="color:var(--eq-ink-3);font-size:var(--eq-text-s)">Shipped by the platform team. Questions go to #platform-help.</p></div>

Props

PropTypeDefaultWhat it does
...restComponentProps<"div">noneEvery native div attribute. Set style.maxWidth or a class to change the 68ch measure.

Anatomy

root.eq-proseA div that styles h1 to h4, p, a, lists, blockquote, hr, code, pre, img, figure and table inside it.

Notes

  • Every rule for the content is wrapped in :where(), so it weighs nothing and any class you put on an element inside wins over it.
  • Prose styles the HTML it is given and adds no semantics: the source still has to use real headings in order, real lists and alt text on every image.
  • Links inside get the same underline and focus ring as Link, so their text must also make sense out of context.
  • Wide tables and code blocks are the content's to manage: pre scrolls sideways, and a table wider than the measure should sit in a scroll container.
  • The measure is 68ch at --eq-text-l, close to 70 characters a line, which is comfortable for long reading.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/prose.tsx13 lines
import type { ComponentProps } from "react";
import "./prose.css";

export type ProseProps = ComponentProps<"div">;

/** Styles the plain HTML inside it, such as rendered markdown or CMS content. */
export function Prose({ className, children, ...rest }: ProseProps) {
  return (
    <div {...rest} className={["eq-prose", className].filter(Boolean).join(" ")}>
      {children}
    </div>
  );
}
components/equip/prose.css198 lines
@layer equip {
  /* Every rule inside is wrapped in :where(), so it weighs nothing: any class a product
     puts on an element in the content, or on a part placed inside, wins over it. */
  .eq-prose {
    --_flow: var(--eq-space-4);

    /* As a flex or grid item it would otherwise grow to fit its longest code line. */
    min-width: 0;
    max-width: 68ch;
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-l) / 1.65 var(--eq-font);
    text-wrap: pretty;
    overflow-wrap: break-word;
  }

  /* Rhythm: every block starts flush and takes its space from the block before it. */
  :where(.eq-prose) :where(h1, h2, h3, h4, p, ul, ol, blockquote, pre, table, figure, hr) {
    margin-block: 0;
  }

  :where(.eq-prose) > :where(* + *) {
    margin-top: var(--_flow);
  }

  /* Headings: same steps as Heading. More room above a heading than below it ties it to its section. */
  :where(.eq-prose) :where(h1, h2, h3, h4) {
    color: var(--eq-ink);
    font-weight: var(--eq-weight-strong);
    text-wrap: balance;
  }

  :where(.eq-prose) :where(h1) {
    font-size: calc(var(--eq-text-l) * 2);
    line-height: 1.15;
    letter-spacing: -0.022em;
  }

  :where(.eq-prose) :where(h2) {
    font-size: calc(var(--eq-text-l) * 1.5);
    line-height: 1.2;
    letter-spacing: -0.016em;
  }

  :where(.eq-prose) :where(h3) {
    font-size: calc(var(--eq-text-l) * 1.25);
    line-height: 1.25;
    letter-spacing: -0.01em;
  }

  :where(.eq-prose) :where(h4) {
    font-size: var(--eq-text-l);
    line-height: 1.35;
  }

  :where(.eq-prose) > :where(h2, h3, h4):where(:not(:first-child)) {
    margin-top: var(--eq-space-6);
  }

  :where(.eq-prose) > :where(h1, h2, h3, h4) + :where(*) {
    margin-top: var(--eq-space-3);
  }

  /* Inline */
  :where(.eq-prose) :where(strong, b) {
    font-weight: var(--eq-weight-strong);
  }

  :where(.eq-prose) :where(a) {
    border-radius: 2px;
    color: var(--eq-ink);
    text-decoration-line: underline;
    text-decoration-color: color-mix(in srgb, currentColor 42%, transparent);
    text-decoration-thickness: 1px;
    text-underline-offset: 0.22em;
    transition:
      text-decoration-color var(--eq-dur) var(--eq-ease),
      text-decoration-thickness var(--eq-dur) var(--eq-ease);
  }

  @media (hover: hover) {
    :where(.eq-prose) :where(a):hover {
      text-decoration-color: currentColor;
      text-decoration-thickness: 2px;
    }
  }

  :where(.eq-prose) :where(a):focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  :where(.eq-prose) :where(code) {
    padding: 0.1em 0.3em;
    border-radius: var(--eq-radius-s);
    background: var(--eq-tint);
    font-family: var(--eq-font-mono);
    font-variation-settings: var(--eq-font-mono-settings);
    font-size: 0.875em;
    overflow-wrap: anywhere;
  }

  /* Lists: the bullets hang in the margin so the text lines up with the paragraphs. */
  :where(.eq-prose) :where(ul, ol) {
    padding-inline-start: 1.5em;
  }

  :where(.eq-prose) :where(ul) {
    list-style: disc;
  }

  :where(.eq-prose) :where(ol) {
    list-style: decimal;
  }

  :where(.eq-prose) :where(li + li, li > ul, li > ol) {
    margin-top: var(--eq-space-1);
  }

  :where(.eq-prose) :where(li)::marker {
    color: var(--eq-ink-3);
  }

  :where(.eq-prose) :where(blockquote) {
    padding-inline-start: var(--eq-space-4);
    border-inline-start: 3px solid var(--eq-line-strong);
    color: var(--eq-ink-2);
  }

  :where(.eq-prose) :where(hr) {
    height: 0;
    margin-block: var(--eq-space-6);
    border: 0;
    border-top: 1px solid var(--eq-line);
  }

  :where(.eq-prose) :where(pre) {
    padding: var(--eq-space-3) var(--eq-space-4);
    overflow-x: auto;
    border: 1px solid var(--eq-line);
    border-radius: var(--eq-radius-m);
    background: var(--eq-tint);
    font: var(--eq-weight) var(--eq-text-s) / 1.6 var(--eq-font-mono);
    font-variation-settings: var(--eq-font-mono-settings);
    tab-size: 2;
    white-space: pre;
  }

  :where(.eq-prose) :where(pre code) {
    padding: 0;
    border-radius: 0;
    background: none;
    font-size: inherit;
    overflow-wrap: normal;
  }

  :where(.eq-prose) :where(img, video) {
    display: block;
    max-width: 100%;
    height: auto;
    border-radius: var(--eq-radius-m);
  }

  :where(.eq-prose) :where(figure) {
    margin-inline: 0;
  }

  :where(.eq-prose) :where(figcaption) {
    margin-top: var(--eq-space-2);
    color: var(--eq-ink-3);
    font-size: var(--eq-text-s);
    line-height: 1.5;
  }

  /* Tables: rules between rows only; columns are told apart by alignment. */
  :where(.eq-prose) :where(table) {
    width: 100%;
    border-collapse: collapse;
    font-size: var(--eq-text-m);
    line-height: 1.5;
    font-variant-numeric: tabular-nums;
  }

  :where(.eq-prose) :where(th, td) {
    padding: var(--eq-space-2) var(--eq-space-3);
    border-bottom: 1px solid var(--eq-line);
    text-align: start;
    vertical-align: top;
  }

  :where(.eq-prose) :where(th:first-child, td:first-child) {
    padding-inline-start: 0;
  }

  :where(.eq-prose) :where(th) {
    border-bottom-color: var(--eq-line-strong);
    font-weight: var(--eq-weight-strong);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index