diff --git a/.gitignore b/.gitignore index ad40a1be..ee57f530 100644 --- a/.gitignore +++ b/.gitignore @@ -160,4 +160,7 @@ agent-reference.local.json .venv/ __pycache__/ *.pyc -.pytest_cache/ \ No newline at end of file +.pytest_cache/ + +# Pages the page lab builds from its samples +tools/page-lab/built/ diff --git a/AGENTS.md b/AGENTS.md index 0c8179bb..6b9f0fbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,7 @@ HTML is exempt, by `**/*.html` in `.oxfmtrc.json`. Formatting it broke tags acro ## Pages: a method, recipes and kits, never templates -`skills/instrument-page` makes one self-contained HTML page, and it has no page types. It is a method (find the point, direct a shape, a feel and a hero, lead with the point), a stylesheet the agent never reads (`lib/foundation.css` and `lib/foundation.js`, which `page.mjs` writes into the page), a check in two halves (`page.mjs` reads every rule the file itself shows, and `lib/probe.js`, which the agent evaluates in its own browser tool, measures layout at desktop, link-preview, dark and phone sizes; each prints one FAIL line per problem), recipes (`cookbook.md`: techniques such as a comparison matrix, a ranked pick, a timeline, steps, figures with a chart), and kits (`kits//`: map, playground, storyboard, whiteboard), which are capabilities a page reaches for, not kinds a user picks. It replaced twenty-eight templates because an agent handed a template fills it in, and pages made that way came out near-identical and scored worst in evaluation. See [`docs/decisions/2026-10-05-create-page-is-a-method-with-kits.md`](docs/decisions/2026-10-05-create-page-is-a-method-with-kits.md). +`skills/instrument-page` makes one self-contained HTML page, and it has no page types. It is a method (find the point, direct a layout, a feel and a hero, lead with the point), a stylesheet the agent never reads (`lib/foundation.css` and `lib/foundation.js`, which `page.mjs` writes into the page), a check in two halves (`page.mjs` reads every rule the file itself shows, and `lib/probe.js`, which the agent evaluates in its own browser tool, measures layout at desktop, link-preview, dark and phone sizes; each prints one FAIL line per problem), recipes (`cookbook.md`: techniques such as a comparison matrix, a ranked pick, a timeline, steps, figures with a chart), and kits (`kits//`: map, playground, storyboard, whiteboard), which are capabilities a page reaches for, not kinds a user picks. It replaced twenty-eight templates because an agent handed a template fills it in, and pages made that way came out near-identical and scored worst in evaluation. See [`docs/decisions/2026-10-05-create-page-is-a-method-with-kits.md`](docs/decisions/2026-10-05-create-page-is-a-method-with-kits.md). - **Do not add a template, an example gallery or a page type.** A new kind of page is a recipe in `cookbook.md` when it is a technique, or a kit when it needs a library wired up or a drawing kit that would be wrong to retype. Never a second skill that makes pages: every skill's description shares the agent's catalog budget. - `SKILL.md` names every recipe by its heading and links every kit's `README.md` and every file in `references/`. The runtime hands an agent at most fifty filenames from a loaded skill, depth-first, so a file the router does not name is one an agent may never open. `pnpm check:page-skill` enforces that, the description's 400-character budget, the fifty-file ceiling, and that every kit's `skeleton.html` is a page `page.mjs` accepts. @@ -89,6 +89,7 @@ HTML is exempt, by `**/*.html` in `.oxfmtrc.json`. Formatting it broke tags acro - `lib/check.mjs` lists the hosts a page may load from, and `allowed-sources.json` at the root is what the platform's pages worker derives every hosted copy's `connect-src` from, so an origin added there needs a pages deploy before shared pages honor it. `pnpm check:page-skill` fails when the check allows a host the file does not. - **A page skill starts no process and opens no browser.** Inside the user's app, launching their real browser raises an operating system permission prompt in the app's name. Layout is checked by `lib/probe.js` in the agent's own browser tool. Publishing stays with `share.mjs` and `share.py`, since many hosts cannot run the page's Share button. `scripts/page-skill.test.ts` fails when any file in instrument-page or wireframe names a process call, or a network write outside the share scripts. See [`docs/decisions/2026-10-06-page-skills-launch-nothing.md`](docs/decisions/2026-10-06-page-skills-launch-nothing.md). - `tools/page-check` is the Chromium check for evals and development: `page.mjs`, then the same `lib/probe.js` over the DevTools protocol, then screenshots. It runs only a downloaded Chromium (`PAGE_CHECK_CHROME`, or agent-browser's, Playwright's or Puppeteer's cache), never an installed browser. Nothing under `skills/` imports it. +- `tools/page-lab` is a local lab for designing the stylesheet: a specimen of every token and class, six sample pages, and a panel that switches feel, layout and theme, all reading `foundation.css` live (`pnpm page-lab`). Its samples are fixtures for checking the stylesheet; never copy one into the skill or show one to an agent. - `skills/wireframe` is its own skill, because wireframes are drawn in Tailwind's vocabulary on their own shell (`shell.html`, which carries the old starter's skin, compiled sheet and share widget), and projects carry their own wireframe kits in that vocabulary. - An example page in a recipe or kit exists to show the shape, not to be authoritative. Name things that exist, and link them to their maker's or vendor's own page, never a deep SKU, a search result or anything that rots: an agent copying a page keeps its links. - Numbers are the easiest thing for an agent to invent and the hardest for a reader to check. A page carries a figure only when the prompt, a source, or arithmetic over shown inputs gave it; scores use coarse scales (letters, a few dots, a word) unless every input is on the page, and never a decimal that implies precision the research does not have. diff --git a/docs/decisions/2026-10-24-pages-are-neutral-with-four-feels-and-two-layouts.md b/docs/decisions/2026-10-24-pages-are-neutral-with-four-feels-and-two-layouts.md new file mode 100644 index 00000000..11fa702a --- /dev/null +++ b/docs/decisions/2026-10-24-pages-are-neutral-with-four-feels-and-two-layouts.md @@ -0,0 +1,30 @@ +# Pages are neutral, with four feels and two layouts + +Status: accepted, 2026-10-24. Updates [`2026-10-05-create-page-is-a-method-with-kits.md`](2026-10-05-create-page-is-a-method-with-kits.md): the method, the stylesheet, the check, the recipes and the kits stand; what the stylesheet looks like and what the agent picks on `` change. + +## Context + +The foundation stylesheet carried the Instrument brand into every page: teal as the default accent, an uppercase teal kicker, teal step numbers and tags. Pages are meant to be portable containers for any explainer, so the brand read as a template. Several details looked machine-made: a colored stripe down the side of rounded cards, a ring around every tag, rules above and below the footer, a triangle on each disclosure. + +The two attributes the agent set on `` mixed two ideas. Six feels named situations (urgent, warm, crew) next to looks (ledger, festive), so the agent had to map a situation onto a look. Four shapes (card, read, sheet, wall) named how a page is used, but card and read differed by 2rem of column, and sheet and wall were both one wide column. The stylesheet also pushed every grid, card row, flow diagram and four-column table onto a wider track automatically, so most pages had two left edges. + +## Decision + +- **Neutral structure, intentional color.** Text, links, rules, labels and the kicker are neutral in every feel. Anything without `data-tone` or `data-c` is gray. The accent shows only where a page marks something, and SKILL.md tells the agent to mark only what carries the point. The kicker is sentence case in Inter. +- **Four feels, named for how they look, picked by what carries the page:** `editorial` when prose does (a serif), `bold` when one fact does (heavy type, one hot color, square corners), `dense` when tables do (small type, tight rows), and `minimal` for everything else. A feel is a look, not a topic: an incident or a deadline takes the feel its content calls for and marks what is urgent with `data-tone="bad"`. +- **Two layouts.** `data-layout` replaces `data-shape`: `column` (the default) or `wide` (a roster or big table; prose keeps a reading width on the same left edge; prints landscape). Card's real content, that a link dropped in chat must make its point on the first screen, is the cookbook recipe "A one-screen card". +- **One left edge.** Everything sits in the column. `.bleed` is the deliberate way out, for a table or chart that needs the room. +- **Old names keep working.** The stylesheet draws calm as minimal; warm as editorial; urgent, festive and crew as bold; ledger as dense; card and read as column; sheet and wall as wide. `page.mjs` accepts them, and SKILL.md teaches only the new ones. + +## Options weighed + +- **Keep situation names and restyle them.** Urgent lost most of its look once kickers and unmarked elements went neutral, and any feel can show urgency through marks. A situation name asks the agent to translate; a look name does not. +- **More feels (swiss, colorful, warm, technical, cute).** Each overlapped another: Swiss with bold, colorful with `data-c` in any feel, warm with editorial, technical with code blocks that already sit well in every feel. Every feel has to be crafted and checked in light, dark and phone, so fewer, better ones won. +- **Keep shapes and fix their widths.** The CSS behind four shapes was two widths; the rest was writing advice, which belongs in the method. +- **Keep modules wide by default and add `.narrow`.** Readers found pages with two edges chaotic; opting in to width is rarer and safer than opting out. + +## Consequences + +- `--pill` sets the rounding of buttons, tags, bars, dots and step numbers; bold sets it to 0. `--fill` is the neutral tint for unmarked things. +- The layout probe's "cramped" rule now leads with fewer columns or shorter cells before `.bleed`. +- A page using a removed name that never shipped (colorful, swiss, technical) fails the check. diff --git a/knip.config.ts b/knip.config.ts index d00b2e0d..fb58502c 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -19,6 +19,8 @@ const config: KnipConfig = { "skills/instrument-page/lib/foundation.*", "skills/instrument-page/lib/probe.js", "skills/wireframe/*.mjs", + // The page lab's panel, which its HTML pages load by a script tag. + "tools/page-lab/lab.js", ], ignoreBinaries: [ "actionlint", // Used by scripts/check-actions.ts diff --git a/package.json b/package.json index 5130f3fc..57eda6d4 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "test:scripts": "vitest run --project scripts", "generate:skills-md": "node scripts/generate-skill-md.ts", "page-check": "node tools/page-check/check.mjs", + "page-lab": "node tools/page-lab/serve.mjs", + "page-lab:build": "node tools/page-lab/build.mjs", "syncpack": "syncpack", "update:actions": "actions-up" }, diff --git a/scripts/check-page-skill.ts b/scripts/check-page-skill.ts index 0ffd54a2..ae50a5d0 100644 --- a/scripts/check-page-skill.ts +++ b/scripts/check-page-skill.ts @@ -103,7 +103,7 @@ function checkSkeletons(errors: string[]) { const wants: [RegExp, string][] = [ [/^/i, "a doctype on the first line"], [/ - + + The late bus costs less

For class parents

The late bus costs less

@@ -136,7 +136,7 @@ ${extra} }); it( - "reports a table squeezed into the column, not one that went wide", + "reports a table squeezed into the column", { timeout: 120000 }, async () => { const cell = diff --git a/scripts/page-skill.test.ts b/scripts/page-skill.test.ts index 9ae76a14..3bb27960 100644 --- a/scripts/page-skill.test.ts +++ b/scripts/page-skill.test.ts @@ -89,17 +89,21 @@ describe("wireframe's copies", () => { describe("page.mjs", () => { const PAGE = join(SKILLS, "instrument-page", "page.mjs"); const run = promisify(execFile); - const page = (body: string) => ` - - + const page = ( + body: string, + attrs = 'data-layout="column" data-feel="minimal"', + ) => ` + + The late bus costs less ${body}`; const check = async ( body: string, inputs = "The quote says $48 and $36.", + attrs?: string, ) => { const dir = mkdtempSync(join(tmpdir(), "page-mjs-")); - writeFileSync(join(dir, "page.html"), page(body)); + writeFileSync(join(dir, "page.html"), page(body, attrs)); writeFileSync(join(dir, "request.md"), inputs); const result = await run( "node", @@ -123,7 +127,7 @@ describe("page.mjs", () => { expect(result.built).toContain(" - + + The client retries once

The client retries once

${code}
`, ); @@ -225,8 +254,8 @@ describe("page.mjs embeds images", () => { writeFileSync( join(dir, "page.html"), ` - - + + The red bike fits

The red bike fits

${body}
`, ); @@ -259,7 +288,7 @@ describe("page.mjs embeds images", () => { expect(result.stdout.replace(/:\d+\//, ":PORT/")).toMatchInlineSnapshot(` "embedded shots/bike.png: PNG 480x320, 1 KB in the page embedded http://127.0.0.1:PORT/a.png?x=1&y=2: PNG 300x200, 1 KB in the page - page.html: pass, as far as the file shows. It is 59 KB. Layout is not checked yet: run lib/probe.js on it in a browser (SKILL.md, step 5)." + page.html: pass, as far as the file shows. It is 64 KB. Layout is not checked yet: run lib/probe.js on it in a browser (SKILL.md, step 5)." `); expect(result.built).not.toContain("shots/bike.png"); expect(result.built).not.toContain("127.0.0.1"); @@ -280,7 +309,7 @@ describe("page.mjs embeds images", () => { "FAIL image-oversize: "big.png": 600 KB (PNG 2400x1600), over 500 KB -> save it again beside the page as JPEG quality 78 (a screenshot may stay PNG if that is smaller), at most 1600 pixels on the long edge for a screenshot and 720 for a photo, and name that file (references/images.md) FAIL image-missing: "gone.png": no file at gone.png -> name a file that exists, relative to the page, or draw a stand-in as inline SVG FAIL image-not-image: "note.png": these bytes are not a PNG, JPEG, GIF, WebP, AVIF or SVG -> name the image itself, not a page that shows it - page.html: 3 FAIL. Fix each by fixing what it names, then run again. It is 59 KB." + page.html: 3 FAIL. Fix each by fixing what it names, then run again. It is 64 KB." `); }); @@ -308,7 +337,7 @@ describe("page.mjs embeds images", () => { "embedded fonts/brand.woff2: WOFF2 font, 3 KB in the page FAIL font-missing: CSS url() "fonts/gone.otf": no file at fonts/gone.otf -> name the font file itself (an installed font is in the system's font folders, references/loading.md), or use a Google Font FAIL font-local: @font-face "Trial Face": names only local() fonts, which readers who lack them never see -> add url() naming the font file beside local() and page.mjs embeds it (references/loading.md, A font of its own), or use a Google Font - page.html: 2 FAIL. Fix each by fixing what it names, then run again. It is 62 KB." + page.html: 2 FAIL. Fix each by fixing what it names, then run again. It is 67 KB." `); }); }); diff --git a/skills/instrument-page/SKILL.md b/skills/instrument-page/SKILL.md index 5b1450a3..5eb46b17 100644 --- a/skills/instrument-page/SKILL.md +++ b/skills/instrument-page/SKILL.md @@ -5,7 +5,7 @@ description: "Use when an answer is longer than a few paragraphs, better seen th # Instrument page -You write one HTML file by hand: plain elements, inline SVG, a little script where it helps the reader. A stylesheet you never read makes plain HTML look like the house; two attributes on `` set its shape and feel. Your job is what no stylesheet can do: find the point, then show it. There are no page types to pick from: every page is designed for its reader, from the method below, the recipes, and a kit when the page needs one. +You write one HTML file by hand: plain elements, inline SVG, a little script where it helps the reader. A stylesheet you never read makes plain HTML look like the house; two attributes on `` set its layout and feel. Your job is what no stylesheet can do: find the point, then show it. There are no page types to pick from: every page is designed for its reader, from the method below, the recipes, and a kit when the page needs one. `` below is the full path to this skill's folder. Run commands from the task folder. Never open `lib/`: it is internals, and the commands print all you need. @@ -20,14 +20,14 @@ Read the inputs one at a time, appending each one's facts to `analysis.md` (peop Compute every total, date, weekday and difference with a short script over the input files, never in your head. Put the analysis in the direction comment, the first line under the doctype: ```html - + ``` -## 2. Direct it: shape, feel, hero +## 2. Direct it: layout, feel, hero -`data-shape` is how it is used: `card` (a link dropped in chat; the first screen is the point), `read` (top to bottom: a summary, a decision), `sheet` (worked from or printed: a schedule, a roster; prints landscape), `wall` (posted or projected). +`data-layout` is the width of the page's one column: `column` (the default, for almost every page) or `wide` (a roster, a schedule or a big table that people work from or print; it prints landscape, and its prose keeps a reading width). A page that will be dropped in chat as a link is still `column`; its reader sees the preview and maybe one screen, so write that screen to carry the whole point (the recipe "A one-screen card"). -`data-feel` is how it lands: `calm` (default), `urgent` (incidents, deadlines), `warm` (family, home), `crew` (work floors, gloves-on phones), `ledger` (money, audits), `festive` (launches, thanks). +`data-feel` is how the page looks. Pick it by what carries the page: `editorial` when prose carries it (a long read, an analysis, a letter; set in a serif), `bold` when one fact carries it (a launch, a milestone, a thank-you; heavy type and one hot color), `dense` when tables carry it (a roster, an audit, a long timeline; small type and tight rows), and `minimal` for everything else, which is most pages. A feel is a look, not a topic: an incident or a deadline takes the feel its content calls for and marks what is urgent with `data-tone="bad"` or `"warn"`. Code, logs and technical detail sit well in any feel. **The hero** is the one thing a chat message could not be, and the best expression of the point. When the point is about amounts, time, places or a schedule, that is a chart or a drawing, and it wins the first screen. When it is a decision or a single figure, it may be a big number with its sentence, a ranked list with one picked, or a then-versus-now. Draw only from real data or real geometry: a sketch with guessed positions is worse than no drawing. @@ -47,8 +47,8 @@ No paragraph over 60 words outside the footer; most under 30. Pick one or two th ```html - - + + The point, 10 words or fewer `, escaped (`<`, `&`): it is highlighted, and every `pre` gets Copy and Wrap. `data-file` labels the block with where it came from; leave it off when nothing does. Lines wrap unless the `pre` says `data-wrap="off"`, for output whose columns line up. `language-diff` shows a change. Terminal output is a bare `
` with no ``, and `` inside it marks a line. Never color code by hand or build your own copy button.
 
-**Layout classes**, the whole list: `.stack` `.row` (`.row.between`) `.grid` (`style="--min:14rem"`) `.split` (main plus side column, stacks on phones) `.rail` `.cols` `.panel` `.inset` `.stat` (`

11%of requests failed

`) `.kicker` `.lede` `.label` `.muted` `.mono` `.n` (tabular numbers) `.tag` `.dot` `.bar` (`style="--v:40%"`) `ol.steps` `ol.flow` (a call path or pipeline of up to six boxes with arrows, stacked on phones: `
  • retryOncewaits on a 429
  • `, `data-tone` on the one that matters) `ul.ticks` (done or true: a check mark each, `data-tone="bad"` a cross) `ul.todo` (still to do: an empty box each) `ul.plain` `table.cards` (rows become cards on phones; give each `td` a `data-label`) `.frames` (a numbered contact sheet of `figure`s, each picture letterboxed in an equal cell, `style="--ratio:16/10"`) `.pair` (two or three `figure`s side by side, such as before and after) `.scroll` `.bleed` (wider) `.narrow` `.full` `.sticky` `.phone-only` `.wide-only` `.print-only` `.screen-only` `.page-break`. +**Layout classes**, the whole list: `.stack` `.row` (`.row.between`) `.grid` (`style="--min:14rem"`) `.split` (main plus side column, stacks on phones) `.rail` `.cols` `.panel` `.inset` `.stat` (`

    11%of requests failed

    `) `.kicker` `.lede` `.label` `.muted` `.mono` `.n` (tabular numbers) `.tag` `.dot` `.bar` (`style="--v:40%"`) `ol.steps` `ol.flow` (a call path or pipeline: small boxes joined by drawn arrows, wrapping onto a new row when they run out of room: `
  • retryOncewaits on a 429
  • `; an identifier goes in ``, a plain step does not, and `data-tone` goes on the one that matters) `ul.ticks` (done or true: a check mark each, `data-tone="bad"` a cross) `ul.todo` (still to do: an empty box each) `ul.plain` `table.cards` (rows become cards on phones; give each `td` a `data-label`) `.frames` (a numbered contact sheet of `figure`s, each picture letterboxed in an equal cell, `style="--ratio:16/10"`) `.pair` (two or three `figure`s side by side, such as before and after) `.scroll` `.bleed` (wider) `.full` `.sticky` `.phone-only` `.wide-only` `.print-only` `.screen-only` `.page-break`. -**Widths**: prose, headings and lists sit in a reading column; dense modules take the wide track beside it by themselves: a table of four or more columns (bare, or as the table of a `.scroll` or `figure`), `.grid`, `.rail`, `.cols` and `ol.flow`. Put `.bleed` on anything else that needs the room, such as a chart, a three-column table of long cells, or a grid of your own, and `.narrow` on a module that should stay in the column. A section of prose never goes wide. +**Widths**: everything sits in one reading column, so the page keeps one left edge: prose, tables, grids, charts and cards alike. Put `.bleed` on the rare module that truly needs more room, such as a table whose cells would wrap to a word or two, or a chart that needs the width to be read; never on prose, and never on several modules in a row. A top-level `section` is a full-width grid whose children sit in the reading column: put a panel look, a custom grid or `.hero` styling on a `div` or `figure` inside it. In `ol.steps`, wrap each item's content in one `span`. -**Color by meaning**: `data-tone="good|warn|bad|accent|muted"` or `data-c="1"` to `"8"` (one per person, team, option) on any element or SVG shape. In SVG, `class="outline|ink|muted|paper|wash|line|faint"`. In your own CSS use only tokens: `--ink --ink-2 --muted --line --line-2 --paper --ground --accent --good --warn --bad` (each with `-wash`), `--c1`..`--c8`, `--tone`, `--radius`, `--mono`, `--serif`. Never hex: the page follows the reader's light or dark theme. +**Color by meaning**: `data-tone="good|warn|bad|accent|muted"` or `data-c="1"` to `"8"` (one per person, team, option) on any element or SVG shape. Everything is neutral until you mark it, so mark only what carries the point, and the color will mean something. In SVG, `class="outline|ink|muted|paper|wash|line|faint"`. In your own CSS use only tokens: `--ink --ink-2 --muted --line --line-2 --paper --ground --accent --good --warn --bad` (each with `-wash`), `--c1`..`--c8`, `--tone`, `--radius`, `--pill` (the rounding of buttons, tags and bars; 0 in bold), `--bar` (the thickness of a bar; size the bars of a chart you build from it, as `calc(var(--bar) * 3)`, so they thin out in dense), `--mono`, `--serif`. Never hex: the page follows the reader's light or dark theme. **Behaviors by attribute**: `data-who="maya"` on things plus `` (dims the rest; the link becomes `#who=maya`); ``; `data-date="2026-10-13"` outlines that day when it is today. Links to other sites get their site's icon. A picture in a `figure` or on its own line that the page shows smaller than it is opens full size on click, with arrows, swipe and a count to step through the others; never build your own viewer, and `data-gallery="off"` keeps one out. @@ -130,7 +130,7 @@ Read the lines it returns and fix every FAIL, then run `page.mjs` again, reopen ## 7. Options and sculpting -When the user asks for options or the right page is unclear, make two or three takes that lead with different heroes or shapes, each its own checked file; offer them a line each and recommend one. Translate the reply before editing: "less scary" is a feel, "I just want to send it in Slack" is a shape, "lead with who drives" is a new hero. +When the user asks for options or the right page is unclear, make two or three takes that lead with different heroes or feels, each its own checked file; offer them a line each and recommend one. Translate the reply before editing: "less scary" is a feel, "I just want to send it in Slack" is the one-screen card, "lead with who drives" is a new hero. ## 8. Recipes diff --git a/skills/instrument-page/cookbook.md b/skills/instrument-page/cookbook.md index 95aa5f57..923cc857 100644 --- a/skills/instrument-page/cookbook.md +++ b/skills/instrument-page/cookbook.md @@ -209,7 +209,7 @@ A vertical list is the phone answer and often the desktop one too. top: 0.4rem; width: 0.7rem; height: 0.7rem; - border-radius: 50%; + border-radius: var(--pill); background: var(--tone); } @@ -456,11 +456,11 @@ When the answer depends on one number the reader knows better than you (hours of ## A one-screen card -For a link dropped in chat (`data-shape="card"`): kicker, a short h1, the hero, and at most three short lines under it. Nothing else above the fold, and the page may end there. Do not center it vertically. The hero can be one chart, one big figure with its sentence, or a short ranked list with the pick marked. +For a link dropped in chat, in the `column` layout: kicker, a short h1, the hero, and at most three short lines under it. Nothing else above the fold, and the page may end there. Do not center it vertically. The hero can be one chart, one big figure with its sentence, or a short ranked list with the pick marked. ## A print sheet -`sheet` and `wall` print landscape. Print-only furniture is HTML: ``. Blanks to fill by hand are `ul.todo` or empty table cells with height. `.page-break` starts a new page. One sheet per person or per day if that is how it will be used. +The `wide` layout prints landscape; for one table or drawing that needs it in a `column` page, put `.print-landscape` on `body`. Print-only furniture is HTML: ``. Blanks to fill by hand are `ul.todo` or empty table cells with height. `.page-break` starts a new page. One sheet per person or per day if that is how it will be used. ## Evidence and drill-in diff --git a/skills/instrument-page/kits/map/README.md b/skills/instrument-page/kits/map/README.md index 35b2b420..50fd9764 100644 --- a/skills/instrument-page/kits/map/README.md +++ b/skills/instrument-page/kits/map/README.md @@ -15,7 +15,7 @@ Not for a dataset of thousands of rows (that is a table with a filter, perhaps w ## How it sits on the foundation -A map page is an ordinary page: it has the direction comment, ``, a `` and description, one `h1` (in the card), and the map element marked `class="hero"`. It is built and checked like any other: +A map page is an ordinary page: it has the direction comment, `<html data-layout="column" data-feel="...">`, a `<title>` and description, one `h1` (in the card), and the map element marked `class="hero"`. It is built and checked like any other: ``` node <skill>/page.mjs page.html --inputs request.md <input files> diff --git a/skills/instrument-page/kits/map/skeleton.html b/skills/instrument-page/kits/map/skeleton.html index 8bb8f6c5..a920e309 100644 --- a/skills/instrument-page/kits/map/skeleton.html +++ b/skills/instrument-page/kits/map/skeleton.html @@ -1,6 +1,6 @@ <!doctype html> -<!-- direction: reader="WHO opens it, on what, to go where" point="WHAT the places' positions say that a list would not" conflicts="none" shape=card feel=calm hero="the map, framed on the places, with the list in a card over it" --> -<html lang="en" data-shape="card" data-feel="calm"> +<!-- direction: reader="WHO opens it, on what, to go where" point="WHAT the places' positions say that a list would not" conflicts="none" layout=column feel=minimal hero="the map, framed on the places, with the list in a card over it" --> +<html lang="en" data-layout="column" data-feel="minimal"> <head> <title>NAME OF THE MAP, 10 words or fewer diff --git a/skills/instrument-page/kits/playground/skeleton.html b/skills/instrument-page/kits/playground/skeleton.html index c524353d..ca3ad1cd 100644 --- a/skills/instrument-page/kits/playground/skeleton.html +++ b/skills/instrument-page/kits/playground/skeleton.html @@ -1,6 +1,6 @@ - - + + NAME OF THE THING, 10 words or fewer diff --git a/skills/instrument-page/kits/whiteboard/skeleton.html b/skills/instrument-page/kits/whiteboard/skeleton.html index 4a518815..c00281de 100644 --- a/skills/instrument-page/kits/whiteboard/skeleton.html +++ b/skills/instrument-page/kits/whiteboard/skeleton.html @@ -1,6 +1,6 @@ - - + + NAME OF THE BOARD diff --git a/skills/instrument-page/lib/check.mjs b/skills/instrument-page/lib/check.mjs index 3bebc352..11c74c38 100644 --- a/skills/instrument-page/lib/check.mjs +++ b/skills/instrument-page/lib/check.mjs @@ -29,8 +29,12 @@ export const LIMITS = { }; // What the share host takes; the 413 message in share.mjs and share.py names it too. const SHARE_MAX_BYTES = 8 * 1024 * 1024; -const FEELS = ["calm", "urgent", "warm", "crew", "ledger", "festive"]; -const SHAPES = ["card", "read", "sheet", "wall"]; +const FEELS = ["minimal", "editorial", "bold", "dense"]; +// Earlier names, still drawn by the stylesheet, so older pages keep passing. +const OLD_FEELS = ["calm", "warm", "urgent", "festive", "ledger", "crew"]; +const LAYOUTS = ["column", "wide"]; +// data-shape came before data-layout; the stylesheet still draws these. +const OLD_SHAPES = ["card", "read", "sheet", "wall"]; const ALLOWED = [ /^https:\/\/fonts\.googleapis\.com\//, /^https:\/\/fonts\.gstatic\.com\//, @@ -41,7 +45,7 @@ const ALLOWED = [ /^https:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/[\w.-]+\/\d+\.\d+\.\d+([-\w.]+)?\//, ]; const FONTS = - "https://fonts.googleapis.com/css2?family=Inter:wght@400..800&family=JetBrains+Mono:wght@400;600&family=Roboto+Serif:ital,wght@0,400;0,500;1,400;1,500&display=swap"; + "https://fonts.googleapis.com/css2?family=Inter:wght@400..800&family=JetBrains+Mono:wght@400;600&family=Roboto+Serif:ital,wght@0,400..700;1,400..700&display=swap"; // Loaded only by a page with a ; foundation.js runs it on load. const HIGHLIGHT = ''; @@ -168,6 +172,7 @@ const stream = (s) => function fileChecks(own, push, inputText, reported = new Set()) { const htmlTag = own.match(/]*>/i)?.[0] ?? ""; + const layout = htmlTag.match(/data-layout\s*=\s*"([^"]*)"/)?.[1]; const shape = htmlTag.match(/data-shape\s*=\s*"([^"]*)"/)?.[1]; const feel = htmlTag.match(/data-feel\s*=\s*"([^"]*)"/)?.[1]; if (!htmlTag) @@ -175,16 +180,24 @@ function fileChecks(own, push, inputText, reported = new Set()) { "no-html", "the file", "no tag", - 'start from the skeleton in SKILL.md: ', + 'start from the skeleton in SKILL.md: ', ); - else if (!SHAPES.includes(shape ?? "")) + else if ( + layout === undefined + ? !OLD_SHAPES.includes(shape ?? "") + : !LAYOUTS.includes(layout) + ) push( - "shape", + "layout", "", - shape ? `data-shape="${shape}" is not a shape` : "no data-shape", - `use one of ${SHAPES.join(", ")}`, + layout !== undefined + ? `data-layout="${layout}" is not a layout` + : shape + ? `data-shape="${shape}" is not a layout` + : "no data-layout", + `use data-layout="${LAYOUTS.join('" or "')}"`, ); - if (feel && !FEELS.includes(feel)) + if (feel && !FEELS.includes(feel) && !OLD_FEELS.includes(feel)) push( "feel", "", @@ -197,7 +210,7 @@ function fileChecks(own, push, inputText, reported = new Set()) { "direction", "under ", "no comment", - "write it first: reader, point (the buried point in one line), conflicts, shape, feel, hero", + "write it first: reader, point (the buried point in one line), conflicts, layout, feel, hero", ); else if (!/\bpoint\s*=/.test(direction)) push( @@ -324,7 +337,7 @@ function fileChecks(own, push, inputText, reported = new Set()) { "add url() naming the font file beside local() and page.mjs embeds it (references/loading.md, A font of its own), or use a Google Font", ); weekdays(text, inputText, push); - return { shape, feel, title }; + return { layout: layout ?? shape, feel, title }; } const MON = [ diff --git a/skills/instrument-page/lib/foundation.css b/skills/instrument-page/lib/foundation.css index 811a25cf..7b426f8d 100644 --- a/skills/instrument-page/lib/foundation.css +++ b/skills/instrument-page/lib/foundation.css @@ -1,6 +1,6 @@ /* foundation.css: the whole look of a page. Plain elements are styled, a few layout classes exist, and two attributes on switch everything: - data-feel (the token set) and data-shape (widths, density, print). + data-feel (the token set) and data-layout (the width of the column). Everything sits in one cascade layer, so any rule a page writes in its own + + +
    +
    +

    Design lab · foundation.css, read live from the repo

    +

    Specimen

    +

    Every token, element and class a page can use, in whatever feel, layout and theme the Lab panel picks. Edit skills/instrument-page/lib/foundation.css, reload, and it shows here.

    +
    +
    + +
    +

    Four feels are four sets of the same knobs

    +

    A page sets data-feel on <html>, and that swaps the token values below. Nothing else changes: every element reads these tokens, so a feel is entirely this table. Click a card to switch the page to it.

    +
    +
    + +
    + +
    +

    Two layouts set the width of the column

    +

    data-layout="column" is one reading column, for almost every page. wide is one wider column for a roster or a big table; its prose keeps a reading width on the same left edge, and it prints landscape. Everything sits in the column; .bleed and .full go wider only when a page asks.

    +
    +

    the three tracks of the page grid, in this layout

    +
    col
    +
    wide (only what a page marks .bleed)
    +
    full (.full)
    +
    + +
    +

    Color is tokens that follow light and dark

    +

    semantic tokens a page may use: --name and its resolved value in this feel and theme

    +
    +

    categorical, for people, teams, options: data-c="1"…"8"

    +
    +

    tones: data-tone="good|warn|bad|accent|muted" sets --tone and --tone-wash on any element

    +
    + goodwarnbadaccentmuted + c1c2c3c4c5c6c7c8 +
    +

    the raw brand scales the tokens are built from (pages never use these directly)

    +
    +
    + +
    +

    Type is three families and one scale

    +

    Inter for text, Roboto Serif where a feel asks for it (editorial text and headings, and every blockquote), JetBrains Mono for code and, in urgent and ledger, labels.

    +
    +

    Kicker · for whom, when, from what

    hgroup > p:first-child, .kicker

    +

    The point, in ten words

    h1

    +

    One short lede sentence that says why it matters.

    hgroup > h1 + p, .lede

    +

    A section heading that says something

    h2

    +

    A smaller heading

    h3

    +

    Label or h4

    h4, .label

    +

    Body text runs at the base size with a line height of 1.55, which is where most of the reading happens.

    p

    +

    Small print and muted notes.

    small, .muted

    +

    41%a big number with its sentence

    .stat

    +

    retryOnce() ⌘ K

    code, kbd

    +
    +
    +

    inline: a, strong, mark, mark[data-tone], code

    +

    A paragraph with a link, strong words, a highlighted phrase, a bad one, and inline code.

    +
    +
    +

    blockquote (always the serif)

    +

    We kept the quote short and said who said it.

    Sam, kickoff call, Oct 2
    +
    +
    + +
    +

    Plain elements are already designed

    +
    +

    aside (tinted note; takes data-tone)

    +
    + + + +
    +
    +
    +

    ul, ol

    +
    +
    • First thing
    • Second thing, a little longer so it wraps when the column is narrow
    • Third
    +
    1. Open the file
    2. Change the token
    3. Reload
    +
    +
    +
    +

    dl (term on the left, stacks on phones)

    +
    Pickup
    7:30 at the north lot
    Return
    About 4:15, same lot
    Bring
    Lunch, water, a jacket
    +
    +
    +

    details (for an optional drill-in)

    +
    How the total was worked out

    Two quotes, each with a per-seat price and a fixed fee, summed for 48 seats.

    +
    A second drill-in

    Hidden until opened.

    +
    +
    +

    button, .button, .primary, [aria-pressed], [data-c]

    +
    A link as a button
    +
    +
    +

    input, select, textarea

    +
    +
    +
    +

    hr

    +
    +
    +
    + +
    +

    Layout is a dozen classes

    +

    .panel (paper, radius, shadow) and .panel[data-tone] / [data-c] (a muted background in that color)

    +
    +

    .panel

    Paper on the ground, with the feel's radius and shadow.

    +

    data-tone="good"

    A muted background in its tone marks it.

    +

    data-c="3"

    One per person, team or option.

    +
    +

    .inset (a tinted block, no shadow)

    +

    .inset

    .inset data-tone="warn"

    .inset data-c="6"

    +
    +

    .stat, .stat[data-tone]

    +
    +

    11%of requests failed

    +

    41 mincheckout was down

    +

    0orders lost

    +

    3,200teams on the new plan

    +
    +

    .split (main plus a side column; stacks on phones), .split.flip

    +

    Main column

    Side column, 19rem by default (--side)

    +
    +

    .row, .row.between, .stack

    +
    .rowwrapsand centers
    +
    .row.betweenpushes apart
    +
    .stackone under another, with --gap
    +
    +

    .rail (cards in a row; a swipe strip on phones)

    +
    +

    Maya

    Drives Friday

    +

    Jon

    Brings snacks

    +

    Priya

    Books the room

    +

    Theo

    Sends the recap

    +
    +

    .cols (newspaper columns)

    +

    The first paragraph flows into columns at the wide track. It is useful for a long list of short things, such as a packing list or a glossary.

    Each child avoids breaking across columns, so a paragraph stays whole.

    A third paragraph to show the flow continuing.

    +

    .tag, .dot, .bar (--v)

    +
    +
    .tagon trackat risk.dot bad.dot c3
    + + + +
    +
    +
    + +
    +

    Sequences and checklists

    +

    ol.steps (each item's content in one span)

    +
    1. Open the quote. Find the line for the return trip.
    2. Ask for the missing leg. Send the vendor one sentence.
    3. Compare totals. Both quotes now cover both legs.
    +
    +

    ol.flow (boxes joined by drawn arrows; they wrap to a new row and the line loops down; <code> for identifiers, data-tone on the one that matters)

    +
      +
    1. clickthe user taps Pay
    2. +
    3. checkout.tsbuilds the order
    4. +
    5. retryOncewaits on a 429
    6. +
    7. payments APIcharges the card
    8. +
    9. receiptback on screen
    10. +
    +

    a longer flow, wrapping onto a second row

    +
      +
    1. Requestthe reader asks for a page
    2. +
    3. Analyzefacts from each input
    4. +
    5. Directlayout, feel, hero
    6. +
    7. Writeone HTML file
    8. +
    9. page.mjsembeds and checks
    10. +
    11. probe.jsmeasures the layout
    12. +
    13. Hand overa path or a link
    14. +
    +

    ul.ticks (li[data-tone="bad"] draws a cross), ul.todo, ul.plain

    +
    +
    • Both legs quoted
    • Insurance included
    • No wheelchair lift
    +
    • Ask for the ride home
    • Confirm the date
    • Collect permission slips
    +
    • ul.plain
    • no markers
    • at all
    +
    +
    +
    + +
    +

    Tables

    +

    table with thead, .n numbers, a toned row and a toned cell

    + + + + + + + +
    PlanPer seatSeatsYear oneStatus
    Starter$1220$2,880Current
    Team$1820$4,320Picked
    Business$3020$7,200Over budget
    +

    table.cards (rows become cards under 40rem; each td has data-label) and caption

    + + + + + + + +
    Resize to phone width to see the rows become cards.
    WhoShiftRole
    MayaMon, WedLead
    JonTue, ThuFloor
    +
    + +
    +

    Code and terminal output

    +

    Code sits on one dark ground in both themes, so its colors are fixed in the stylesheet rather than tokens.

    +

    pre[data-file] > code.language-ts (Copy and Wrap come from foundation.js)

    +
    export async function retryOnce(call: () => Promise<Response>) {
    +  const res = await call();
    +  // A 429 means slow down: wait once, then try again.
    +  if (res.status === 429) {
    +    await sleep(Number(res.headers.get("retry-after") ?? 1) * 1000);
    +    return call();
    +  }
    +  return res;
    +}
    +
    +

    code.language-diff

    +
    - const RETRIES = 5;
    ++ const RETRIES = 1;
    +
    +

    bare pre (terminal) with span[data-tone]

    +
    $ pnpm test
    + ✓ client.test.ts (12)
    + ✗ retry.test.ts (1 failed)
    +  expected 1 call, received 5
    +
    +
    + +
    +

    Pictures and drawings

    +

    .frames (numbered contact sheet, --ratio), figure > figcaption

    +
    +
    Before
    The old layout
    +
    After
    The new layout
    +
    Phone
    A tall picture, letterboxed
    +
    +

    .pair (two or three at their own shapes)

    +
    +
    Before
    Before
    +
    After
    After
    +
    +

    inline SVG: [data-c] and [data-tone] fill; class outline | ink | muted | paper | wash | line | faint

    +
    + + + data-c="1" + data-tone + .outline + .wash c3 + .paper .line + .faint + .ink + text in svg is the body font; .line is a hairline + + +
    A figure caption under a drawing.
    +
    +
    +
    + +
    +

    Behaviors by attribute

    +

    button[data-show-who] dims everything not marked data-who

    +
    +
    • Maya drives Friday
    • Jon brings snacks
    • Both set up chairs
    +
    +

    button[data-copy="#id"]

    +

    Hi! Could you add the ride home to the quote? Thanks.

    +

    +
    +

    [data-date] outlines today (set to today by the lab so you can see it)

    +
    todayanother day
    +
    +
    + +

    Lab page, not a real page: it links foundation.css and foundation.js from the repo instead of embedding them, and the Lab panel is not part of the system.

    + + + + diff --git a/tools/page-lab/lab.js b/tools/page-lab/lab.js new file mode 100644 index 00000000..66dce7b4 --- /dev/null +++ b/tools/page-lab/lab.js @@ -0,0 +1,174 @@ +// lab.js: a switcher for trying any lab page in every feel, layout and theme. +// Lab only; real pages never carry it. The URL keeps the choice, so a reload +// after editing foundation.css stays where you were: +// ?feel=bold&layout=wide&theme=dark set from the URL +// ?bare no panel (feels.html's frames) +// Keys: f next feel, l next layout, t next theme, 0 back to the page's own. +(() => { + const root = document.documentElement; + const FEELS = ["minimal", "editorial", "bold", "dense"]; + const LAYOUTS = ["column", "wide"]; + const THEMES = ["auto", "light", "dark"]; + const own = { + feel: root.dataset.feel || "minimal", + layout: root.dataset.layout || "column", + theme: "auto", + }; + let panel; + const now = () => ({ + feel: root.dataset.feel || "minimal", + layout: root.dataset.layout || "column", + theme: root.dataset.theme || "auto", + }); + + function set(key, value, quiet) { + if (key === "theme") { + if (window.__instrumentTheme) + window.__instrumentTheme(value === "auto" ? "" : value); + else if (value === "auto") delete root.dataset.theme; + else root.dataset.theme = value; + } else root.dataset[key] = value; + if (quiet) return; + const url = new URL(location.href); + for (const [k, v] of Object.entries(now())) + if (v === own[k]) url.searchParams.delete(k); + else url.searchParams.set(k, v); + history.replaceState(null, "", url); + document.dispatchEvent(new CustomEvent("lab:change", { detail: now() })); + draw(); + } + + const q = new URLSearchParams(location.search); + for (const k of ["feel", "layout", "theme"]) + if (q.get(k)) set(k, q.get(k), true); + document.dispatchEvent(new CustomEvent("lab:change", { detail: now() })); + if (q.has("bare")) return; + + const host = document.createElement("div"); + host.setAttribute("data-lab", ""); + const shadow = host.attachShadow({ mode: "open" }); + const icon = (d) => + ``; + const HOME = "M2.5 7.5 8 3l5.5 4.5M4 6.5V13h3V9.5h2V13h3V6.5"; + const DOWN = "M4 6l4 4 4-4"; + const UP = "M4 10l4-4 4 4"; + shadow.innerHTML = `
    ${icon(HOME)}Instrument Pages design lab
    `; + panel = shadow.querySelector(".lab"); + const body = shadow.querySelector(".body"); + const homeHref = new URL(base() + "index.html"); + const home = shadow.querySelector("[data-home]"); + home.href = homeHref.pathname; + if ( + location.pathname === homeHref.pathname || + location.pathname === homeHref.pathname.replace(/index\.html$/, "") + ) + home.setAttribute("aria-current", "page"); + // Every lab page, one click away; the theme you picked comes along. + const PAGES = [ + ["onboarding", "samples/onboarding-emails.html"], + ["outage", "samples/checkout-outage.html"], + ["bus", "samples/bus-quotes.html"], + ["roster", "samples/crew-roster.html"], + ["accounting", "samples/accounting-tools.html"], + ["launch", "samples/launch-thanks.html"], + ]; + const carry = (a, path) => () => { + const theme = now().theme; + if (theme !== "auto") a.href = `${path}?theme=${theme}`; + }; + home.onclick = carry(home, homeHref.pathname); + const pages = document.createElement("div"); + pages.className = "row pages"; + pages.innerHTML = `pages`; + for (const [label, path] of PAGES) { + const a = document.createElement("a"); + a.textContent = label; + const href = new URL(base() + path); + a.href = href.pathname; + if (location.pathname === href.pathname) + a.setAttribute("aria-current", "page"); + a.onclick = carry(a, href.pathname); + pages.append(a); + } + body.append(pages); + const rows = { feel: FEELS, layout: LAYOUTS, theme: THEMES }; + for (const [key, values] of Object.entries(rows)) { + const row = document.createElement("div"); + row.className = "row"; + row.innerHTML = `${key}`; + for (const v of values) { + const b = document.createElement("button"); + b.textContent = v; + b.dataset.key = key; + b.dataset.value = v; + if (own[key] === v && key !== "theme") b.className = "own"; + b.title = own[key] === v ? "this page's own" : ""; + b.onclick = () => set(key, v); + row.append(b); + } + body.append(row); + } + const min = shadow.querySelector("[data-min]"); + min.onclick = () => { + const hidden = panel.classList.toggle("min"); + min.innerHTML = icon(hidden ? UP : DOWN); + min.title = hidden ? "Show" : "Hide"; + min.setAttribute("aria-label", min.title); + }; + document.addEventListener("keydown", (e) => { + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (e.target.closest?.("input, textarea, select, [contenteditable]")) + return; + const cycle = (key) => { + const list = rows[key]; + set(key, list[(list.indexOf(now()[key]) + 1) % list.length]); + }; + if (e.key === "f") cycle("feel"); + else if (e.key === "l") cycle("layout"); + else if (e.key === "t") cycle("theme"); + else if (e.key === "0") for (const k of Object.keys(own)) set(k, own[k]); + }); + const mount = () => document.body.append(host); + if (document.body) mount(); + else addEventListener("DOMContentLoaded", mount); + draw(); + + function draw() { + if (!panel) return; + const state = now(); + for (const b of shadow.querySelectorAll("button[data-key]")) + b.setAttribute( + "aria-pressed", + String(state[b.dataset.key] === b.dataset.value), + ); + } + function base() { + const src = document.currentScript?.src || ""; + return src.slice(0, src.lastIndexOf("/") + 1); + } +})(); diff --git a/tools/page-lab/samples/accounting-tools.html b/tools/page-lab/samples/accounting-tools.html new file mode 100644 index 00000000..b7e01b30 --- /dev/null +++ b/tools/page-lab/samples/accounting-tools.html @@ -0,0 +1,95 @@ + + + + +The cheaper monthly plan costs $1,140 more over three years + + + + + + + + + + + + + +
    +
    +

    For Dana and Luis · two pricing pages, read Oct 22 · at 80 invoices a month

    +

    The cheaper monthly plan costs $1,140 more over three years

    +

    Ledgerly's per-invoice fee overtakes Tallybook's setup fee in month eight.

    +
    +
    + +
    +
    + + + + + + $0 + $1,000 + $2,000 + $3,000 + 0 + 12 + 24 + 36 months + + + + month 8: Ledgerly passes Tallybook + Tallybook $2,064 + Ledgerly $3,204 + +
    Cumulative cost, including Tallybook's $300 setup fee, at 80 invoices a month.
    +
    +
    +

    Three years at 80 invoices a month

    +
    Tallybook $2,064
    +
    Ledgerly $3,204
    +

    Ledgerly passes Tallybook in month 8.

    +
    +
    + +
    +

    The monthly price hides the invoice fee

    + + + + + + + + + +
    LineTallybookLedgerly
    Setup, once$300$0
    Plan, per month$49$29
    Invoices, per month (80 × $0.75)$0$60
    Effective per month$49$89
    Three years$2,064$3,204
    +
    + +
    +

    What would flip it

    +

    Over three years Ledgerly comes out cheaper only at 37 invoices a month or fewer. We send 80.

    +
      +
    • Both export to the bank feed Luis already uses
    • +
    • Both include payroll for up to five people
    • +
    • Tallybook has no mobile receipt capture
    • +
    +
    + +
    +
    + The arithmetic +

    Tallybook: $300 + 36 × $49 = $2,064. Ledgerly: 36 × ($29 + 80 × $0.75) = 36 × $89 = $3,204. Difference: $1,140. Crossing: $300 + $49m = $89m, so m = 7.5.

    +
    +
    + +

    Sample page for the design lab. Both products and their prices are made up.

    + + diff --git a/tools/page-lab/samples/bus-quotes.html b/tools/page-lab/samples/bus-quotes.html new file mode 100644 index 00000000..f8b3d90e --- /dev/null +++ b/tools/page-lab/samples/bus-quotes.html @@ -0,0 +1,76 @@ + + + + +The cheaper bus quote leaves out the ride home + + + + + + + + + + + + + +
    +
    +

    For Room 4 parents · from the two bus quotes, Oct 14

    +

    The cheaper bus quote leaves out the ride home

    +

    Lakeside is the lower price once both legs are counted.

    +
    +
    + +
    +
    + +
    Bar lengths on one scale, up to $1,410.
    +
    +
    + +
    +

    What each quote covers

    + + + + + + +
    CompanyCoversTotal
    Lakeside CoachesBoth legs, driver waits$1,180
    Bluebird CharterThere only; home is a second booking$1,410
    +
    + +
    +

    The day itself

    +
    +
    Pickup
    7:30 at the north lot
    +
    Back
    About 3:45, same lot
    +
    Bring
    Lunch, water, a jacket
    +
    + +
    + +
    +

    A note you can send

    +

    Hi all, the Bluebird quote only covers the ride there. With the ride home it comes to $1,410, so Lakeside at $1,180 is the better deal. Could we book Lakeside by Friday?

    +

    +
    + +

    Sample page for the design lab. The school, companies and prices are made up.

    + + diff --git a/tools/page-lab/samples/checkout-outage.html b/tools/page-lab/samples/checkout-outage.html new file mode 100644 index 00000000..31402836 --- /dev/null +++ b/tools/page-lab/samples/checkout-outage.html @@ -0,0 +1,82 @@ + + + + +Checkout failed for 41 minutes because our retries piled up + + + + + + + + + + + + + +
    +
    +

    Incident review · Tuesday, Oct 20, 14:02 to 14:43

    +

    Our own retries took checkout down for 41 minutes

    +

    A short rate limit from the payment provider became an outage because every request retried five times.

    +
    +
    + +
    +
    +

    41 mincheckout failing

    +

    5×calls per payment while limited

    +
    +
      +
    1. Pay buttoncustomer taps Pay
    2. +
    3. checkout.tsbuilds the order
    4. +
    5. retryPayment5 tries, no wait
    6. +
    7. provider APIanswers 429
    8. +
    9. error screenasks to try again
    10. +
    +
    + +
    +

    What happened, minute by minute

    + + + + + + + + + +
    TimeWhat we saw
    14:02Provider returns 429 for 3% of calls, a normal burst limit.
    14:03Each failed call retries five times at once; traffic to the provider triples.
    14:09Provider limits our whole account. Every checkout fails.
    14:31Retries switched off by flag. Errors start falling.
    14:43Limit lifts. Checkout succeeds again.
    +
    + +
    +

    The fix is one retry that waits

    +

    A 429 tells us when to try again. We now wait that long, once, instead of hammering the provider.

    +
    - const RETRIES = 5;
    +- const WAIT_MS = 0;
    ++ const RETRIES = 1;
    ++ const waitFor = (res: Response) => Number(res.headers.get("retry-after") ?? 1) * 1000;
    +
    + +
    +

    Before Friday

    +
    +
      +
    • Ship the one-retry change behind the existing flag
    • +
    • Alert when 429s pass 1% for two minutes
    • +
    • Update the status page note: the provider was not degraded
    • +
    +
      +
    • No customer was charged twice
    • +
    • Orders placed during the outage were saved
    • +
    • Support saw 212 tickets
    • +
    +
    +
    + +

    Sample page for the design lab. The company, code and figures are made up.

    + + diff --git a/tools/page-lab/samples/crew-roster.html b/tools/page-lab/samples/crew-roster.html new file mode 100644 index 00000000..60ffcc76 --- /dev/null +++ b/tools/page-lab/samples/crew-roster.html @@ -0,0 +1,78 @@ + + + + +Thursday night is two people short + + + + + + + + + + + + + +
    +
    +

    Crew roster · week of Monday, Oct 19 · posted Friday, Oct 16

    +

    Thursday night is two people short

    +

    Every other shift is covered. Gus can take one; the float pool needs to cover the other.

    +
    +
    + +
    +
    + Show my shifts + +
    + + + + + + + + + + + + + + + + + + + + + + + + +
    ShiftMon 19Tue 20Wed 21Thu 22Fri 23Sat 24Sun 25
    Day needs 3
    AnaBenCruz
    AnaBenDee
    AnaCruzFay
    BenCruzDee
    AnaBenCruz
    BenDeeGus
    CruzEliGus
    Night needs 4
    DeeEliFayGus
    CruzEliFayGus
    BenDeeEliGus
    EliFay2 open
    DeeEliFayGus
    AnaCruzEliFay
    AnaBenDeeFay
    +
    + +
    +

    Filling Thursday night

    +
    +
      +
    • Ask Gus: off Thursday day and Friday day, so the rest rule holds
    • +
    • Call the float pool for one more by Saturday noon
    • +
    • Post the updated sheet Sunday
    • +
    + +
    +
    + +

    Sample page for the design lab. The crew, shifts and rule are made up.

    + + diff --git a/tools/page-lab/samples/inputs/launch-thanks.md b/tools/page-lab/samples/inputs/launch-thanks.md new file mode 100644 index 00000000..737e7c4b --- /dev/null +++ b/tools/page-lab/samples/inputs/launch-thanks.md @@ -0,0 +1,2 @@ +Beta feedback, Oct 9, from a field lead at a beta team: +"We ran a whole site survey in a basement and it all synced when we walked out." diff --git a/tools/page-lab/samples/launch-thanks.html b/tools/page-lab/samples/launch-thanks.html new file mode 100644 index 00000000..905dfc3b --- /dev/null +++ b/tools/page-lab/samples/launch-thanks.html @@ -0,0 +1,74 @@ + + + + +Offline mode reached 3,200 teams in one week + + + + + + + + + + + + + +
    +
    +

    All-hands · Friday, Nov 6 · from the launch export

    +

    Offline mode reached 3,200 teams in one week

    +

    We passed the quarter's goal of 2,500 on day five. Thank you to the six people who built it.

    +
    +
    + +
    +
    +
    + +
    Teams with offline mode on, by the end of each day.
    +
    +

    Day 5when we passed the quarter's goal of 2,500

    +
    +
    + +
    +

    The people who made it

    +
    +

    Rosa

    Wrote the sync engine and its conflict rules.

    +

    Kenji

    Made every screen work with no connection.

    +

    Amara

    Tested on the worst train Wi-Fi in the city.

    +

    Owen

    Ran the beta with 40 teams.

    +

    Lin

    Designed the offline badge and sync states.

    +

    Sam

    Wrote the launch post and the help pages.

    +
    +
    + +
    +

    What a beta team told us

    +

    We ran a whole site survey in a basement and it all synced when we walked out.

    Field lead at a beta team, Oct 9
    +
    + +

    Sample page for the design lab. The product, people and figures are made up.

    + + diff --git a/tools/page-lab/samples/onboarding-emails.html b/tools/page-lab/samples/onboarding-emails.html new file mode 100644 index 00000000..957ca23e --- /dev/null +++ b/tools/page-lab/samples/onboarding-emails.html @@ -0,0 +1,90 @@ + + + + +Half our new users stop reading after email one + + + + + + + + + + + + + +
    +
    +

    For the growth team · onboarding export, Sep 1 to Sep 30

    +

    Half our new users stop reading after email one

    +

    The second email lands an hour after the first, before anyone has opened the app.

    +
    +
    + +
    +
    + +
    Share of 12,400 September signups who opened each email at least once.
    +
    +
    + +
    +

    The second email arrives before the first is read

    +

    Email two goes out 60 minutes after signup. Most people who open email one do it the next morning, so they find both at once and open only the top one.

    +
    +
    Email 1
    At signup
    +
    Email 2
    Signup plus 1 hour
    +
    Email 3
    Day 3
    +
    Email 4
    Day 7
    +
    +
    + +
    +

    Readers who get past email two mostly stay

    +

    After the drop, the curve is nearly flat: of the people who open email two, 82% also open email four.

    + + + + + + + + +
    EmailOpenedOf signupsOf previous
    1 · Welcome7,44060% 
    2 · Set up your space3,47028%47%
    3 · Invite a teammate3,10025%89%
    4 · Your first week2,85023%92%
    +
    + +
    +

    Three changes to try, cheapest first

    +
      +
    1. Send email two on day one, after the first session. One trigger change in the sequence settings.
    2. +
    3. Fold the setup steps into email one. Only if the first change does not move opens.
    4. +
    5. Test a shorter subject line on email two. Worth doing alongside either change.
    6. +
    + +
    + +
    +
    + How the percentages were worked out +

    Each email's unique openers divided by 12,400 signups, and by the previous email's openers for the last column. Rounded to whole points.

    +
    +
    + + + + diff --git a/tools/page-lab/serve.mjs b/tools/page-lab/serve.mjs new file mode 100644 index 00000000..964bd756 --- /dev/null +++ b/tools/page-lab/serve.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +// node tools/page-lab/serve.mjs [port] (pnpm page-lab) +// Serves the repo root on 127.0.0.1 so the lab reads foundation.css live from +// skills/instrument-page, and tells the browser never to cache, so a reload +// always shows the stylesheet as it is on disk. +import { createServer } from "node:http"; +import { readFile, stat } from "node:fs/promises"; +import { extname, join, normalize, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../..", import.meta.url)); +const port = Number(process.argv[2]) || 4711; +const TYPES = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".json": "application/json; charset=utf-8", + ".woff2": "font/woff2", + ".md": "text/plain; charset=utf-8", +}; + +createServer(async (req, res) => { + const path = decodeURIComponent(new URL(req.url, "http://x").pathname); + if (path === "/") { + res.writeHead(302, { Location: "/tools/page-lab/" }).end(); + return; + } + let file = normalize(join(root, path)); + if (!file.startsWith(root.endsWith(sep) ? root : root + sep)) { + res.writeHead(403).end(); + return; + } + try { + if ((await stat(file)).isDirectory()) { + // A folder without its slash would resolve the page's relative links one level up. + if (!path.endsWith("/")) { + res.writeHead(301, { Location: `${path}/` }).end(); + return; + } + file = join(file, "index.html"); + } + const body = await readFile(file); + res.writeHead(200, { + "Content-Type": TYPES[extname(file)] ?? "application/octet-stream", + "Cache-Control": "no-store", + }); + res.end(body); + } catch { + res.writeHead(404, { "Content-Type": "text/plain" }).end("Not found"); + } +}).listen(port, "127.0.0.1", () => { + console.log(`Page lab: http://localhost:${port}/tools/page-lab/`); +});