diff --git a/bench/site_facts.py b/bench/site_facts.py index d1cb32b..5bbed0b 100755 --- a/bench/site_facts.py +++ b/bench/site_facts.py @@ -242,6 +242,65 @@ def bench_modules(readme: str, lines: int) -> int: return modules +def context_scope() -> dict: + """What the context-pack figures were measured on. + + THE BUG THIS EXISTS TO PREVENT. The /agents page set the bench's + 30-module project beside the pack size and the token saving, as the + project they were measured on. `bench/context_pack.py` measures neither + there. The pack is measured against the import closure of each sample in + its `TARGETS`, and the token runs ground one task on one of those samples. + So the scope is read out of the script that takes the measurement, the way + the module count is read out of the generator. + + `TARGETS` is a literal, read with `ast` rather than imported, so nothing + in that script runs here. The token task's sample is the entry + `rounds_arm` passes to `pack_for`. It has to be exactly one entry, and + one of the targets, or the two arms are no longer measured on the samples + the page names. + """ + import ast + + script = "bench/context_pack.py" + tree = ast.parse((REPO / script).read_text(encoding="utf-8")) + targets = None + tasks: set[str] = set() + for node in tree.body: + if isinstance(node, ast.Assign) and any( + isinstance(name, ast.Name) and name.id == "TARGETS" for name in node.targets + ): + targets = ast.literal_eval(node.value) + if isinstance(node, ast.FunctionDef) and node.name == "rounds_arm": + tasks |= { + call.args[0].value + for call in ast.walk(node) + if isinstance(call, ast.Call) + and isinstance(call.func, ast.Name) + and call.func.id == "pack_for" + and call.args + and isinstance(call.args[0], ast.Constant) + } + + if not isinstance(targets, dict) or not targets: + raise SystemExit( + f"bench/site_facts.py: `{script}` no longer declares its samples " + f"as a literal `TARGETS`, so the site cannot say what the pack " + f"was measured on." + ) + if len(tasks) != 1 or not tasks <= targets.keys(): + raise SystemExit( + f"bench/site_facts.py: `{script}`'s rounds arm grounds " + f"{sorted(tasks) or 'no literal entry'} with `pack_for`, which is " + f"not one of its targets. The site names the sample the token " + f"runs were measured on, so it has to be exactly one of them." + ) + + return { + "targets": [{"entry": entry, "imports": list(closure)} for entry, closure in targets.items()], + "task": tasks.pop(), + } + + # (key, pattern, group names). A miss is an error, never a default. RECORDED_PATTERNS: list[tuple[str, str, tuple[str, ...]]] = [ ("machine", r"\(([^()]*Ryzen[^()]*)\)", ("name",)), @@ -498,6 +557,7 @@ def build() -> tuple[dict, list[str]]: # README.md names no machine for either, because neither is a # duration, so the stamp says so rather than borrowing one. "context_pack": { + **context_scope(), "size_low": int(raw["context_pack"]["size_low"]), "size_high": int(raw["context_pack"]["size_high"]), "tokens_low": int(raw["context_tokens"]["low"]), diff --git a/doc/ART-DIRECTION.md b/doc/ART-DIRECTION.md index 2c3c0d2..5e22527 100644 --- a/doc/ART-DIRECTION.md +++ b/doc/ART-DIRECTION.md @@ -16,18 +16,19 @@ and to stay honest under the reader who checks: every figure on a page is either counted from the tree or stamped with the machine that measured it, and `scripts/no-transcription.mjs` fails the build if one is typed by hand. -The look is deliberately unremarkable. White ground, a sans-serif, a mono for -anything a machine printed, one accent, rounded cards, and nothing else. The -mascot supplies all the character the page needs; a second ornament would -compete with it. +The look is a dark page with warm greys, a wide sans for the author, a mono +for anything a machine printed, one red accent and a green and an amber that +report how a run ended. Rounded cards on hairlines carry the evidence. The +mascot supplies the character, and the type does the rest: the headings are +set a little wide and tight, so they carry weight without going to black. ## The mascot `src/assets/mascot.png`, cut out of its background so it sits on the page -ground in either scheme. It appears in four places and no more: large in the -hero, small beside the wordmark in the header, at the foot of every page, and -as the favicon, cropped to the face. It is -never used as a bullet, a spinner, a badge or a reaction. The `i` on each +ground in either scheme. It appears beside the wordmark in the header and at +the foot of every page, small in front of the eyebrow that opens each section +page, and as the favicon, cropped to the face. It is never used as a bullet in +a list, a spinner or a reaction. The `i` on each cheek is the tittle, the dot that in Turkish orthography separates `i` from dotless `ı`; the same dot terminates every section rule on the site. @@ -36,8 +37,8 @@ drawing is 1200px across; the header mark is 28px. Served out of `public/` it was the same file every time, so a reader paid the hero's weight to draw a mark the width of a word, four times a page. Through `astro:assets` each call site asks for the size it draws at, `densities={[1, 2]}` covers a retina screen, and -the build emits exactly those. The hero keeps `fetchpriority="high"` because it -is the largest thing painted above the fold; every other placement is lazy. +the build emits exactly those. The header mark and the eyebrow are eager, +because they are above the fold; the footer's is lazy. ## Palette @@ -46,106 +47,100 @@ are used in is computed by `scripts/contrast.mjs` on each build and the build fails under the floor the pair's role requires, so no ratio is written down here or there: the file that once recorded five of them had four wrong. +The values are the redesign's, and where one of them fell under its floor it +was moved the least distance that clears it, by the gate's own arithmetic: +`--mute` in both schemes, `--boundary` in both, and `--brand-dark`, whose white +button label sat under 4.5. Separators and soft grounds were alphas in the +redesign; here they are the solid colour each composites to over the page, +because an alpha is invisible to the gate. + +**Dark is the default on a screen.** It is the scheme the page is designed in, +and the light one is the reader's choice, remembered. + | Token | Value | Where it is used | |---|---|---| | `--paper` | `#ffffff` | Page ground. | -| `--raise` | `#f7f7f8` | Alternating bands, cards, code blocks, the footer. | -| `--ink` | `#1c1c1e` | Text and headings. | -| `--graphite` | `#4d4f53` | Secondary prose, captions, console output. | -| `--mute` | `#676a6f` | Labels, ticks, elision marks. | -| `--hairline` | `#e3e4e8` | Every separator between two areas of the page. | -| `--track` | `#eceef1` | The unfilled part of a gauge. | -| `--boundary` | `#85888f` | The edge of a control, and nothing else. | -| `--brand` | `#d1333d` | The mascot's red. Buttons, figures, the tittle. | -| `--brand-deep` | `#ad1f29` | Links and any brand text that has to pass AA on white. | -| `--brand-soft` | `#fdeeee` | The tinted ground a caveat sits on. | -| `--glove` | `#ecb643` | The mascot's yellow. Reserved; the mascot carries it. | -| `--signal-deep` | `#8a151d` | The darkest cut of the accent, for a loss set small. | +| `--raise` | `#f6f5f3` | The footer, a console's ground, inline code, a hovered row. | +| `--card` | `#ffffff` | Cards, steps, the tour's groups. | +| `--code` | `#f8f7f5` | A listing's ground. | +| `--ink` | `#0e0c0b` | Text and headings. | +| `--graphite` | `#4a4540` | Secondary prose, leads, captions. | +| `--mute` | `#746d67` | Labels, line numbers, the bars of code frames. | +| `--hairline` | `#e8e7e6` | Every separator between two areas of the page. | +| `--boundary` | `#8d8c8b` | The edge of a control, and nothing else. | +| `--brand` | `#e5203a` | The mascot's red. The primary button, a selected step's mark. | +| `--brand-deep` | `#c8102e` | Links, eyebrows, keywords, and any red set as text. | +| `--brand-soft` | `#fdeff1` | The ground of a red chip or an errored line. | +| `--on-brand` | `#ffffff` | The label on a red button. | +| `--ok` | `#167d3b` | A run that came back clean, strings in a listing, iyi's bar. | +| `--warn` | `#9a5b00` | A run with something to read, a recorded-only step, an edit notice. | + +And the dark scheme, declared under `-dark` names beside the light one and +switched on by aliasing. `--on-brand` is white in both and has no dark name. + +| Token | Value | +|---|---| +| `--paper-dark` | `#0a0908` | +| `--raise-dark` | `#100e0c` | +| `--card-dark` | `#14110f` | +| `--code-dark` | `#0e0c0b` | +| `--ink-dark` | `#f4f1ee` | +| `--graphite-dark` | `#a49d96` | +| `--mute-dark` | `#837d76` | +| `--hairline-dark` | `#1e1b19` | +| `--boundary-dark` | `#66625f` | +| `--brand-dark` | `#df3228` | +| `--brand-deep-dark` | `#ff6157` | +| `--brand-soft-dark` | `#210e0c` | +| `--ok-dark` | `#5fd98a` | +| `--warn-dark` | `#f5b451` | `--signal` is an alias of `--brand-deep`, because the measurement components -address the accent by that name and a loss and a link get the same colour: the -site has one accent, taken from the mascot, and spends it on what a reader can -act on or should notice. +address the accent by that name: a loss and a link get the same colour. `--hairline` and `--boundary` are not interchangeable and the split is the point. A hairline separates two areas of the page, which WCAG 1.4.11 exempts, and it is drawn faint on purpose. A boundary is the edge of a thing you can press, which 1.4.11 does not exempt, and every control on the site draws with -it: the copy button, the ghost CTA, the declaration toggle, the chart's replay -and the menu button. They used to draw with the hairline, which put every -control's edge far under the floor. +it. -### The dark scheme +### The two schemes -This document used to say there was one scheme, because a second palette would -be a second design to keep honest. That objection is answered rather than -abandoned: `scripts/contrast.mjs` computes every declared pair on every build -and fails under the floor, and the dark pairs are in its list, so the second -palette is kept honest by the same machinery as the first. A dark scheme that -was not gated would be exactly the thing the refusal was about. +Three rules hold them together. Every colour is a literal declared once in the +base `:root`, light under its own name and dark under a `-dark` name, and the +scheme switch only aliases them, so no component knows which scheme it is in +and the gate can see both palettes. The switch is `@media screen`, and the +`screen` is load bearing: a print is always the light palette, so printing a +lesson does not empty a toner cartridge into the page. -| Token | Value | Note | -|---|---|---| -| `--paper-dark` | `#141418` | Ground. Not an inverted white, which reads as a smudge. | -| `--raise-dark` | `#1d1d23` | Bands, cards, code blocks. | -| `--ink-dark` | `#f0f0f2` | Text and headings. | -| `--graphite-dark` | `#c3c5cb` | Secondary prose, captions, console output. | -| `--mute-dark` | `#9ba0a8` | Labels, ticks, elision marks. | -| `--hairline-dark` | `#33343c` | Separators. | -| `--track-dark` | `#2b2c33` | Gauge track. | -| `--boundary-dark` | `#6b6d76` | Control edges. | -| `--brand-dark` | `#f4636d` | The accent, lifted so it carries against a dark ground. | -| `--brand-deep-dark` | `#ff8f96` | The cut that passes AA as running text. | -| `--brand-soft-dark` | `#2b171a` | The tinted ground a caveat sits on. | -| `--glove-dark` | `#f0c25e` | Reserved, as in the light scheme. | -| `--signal-deep-dark` | `#ffb3b8` | A loss set small. | - -Three rules hold it together. Every colour is a literal declared once in the -base `:root`, and both scheme switches only alias them, so no component knows -which scheme it is in and the gate can see both palettes. The default switch -is `@media screen and (prefers-color-scheme: dark)`, and the `screen` is load -bearing: a print query matches a reader's colour preference too, and without it -printing a lesson under a dark preference would empty a toner cartridge into -the page. - -And the machine's answer can be overruled. The masthead carries one control, -drawn with the mark of what the press will do, that writes `data-theme` onto -`` and remembers it. It is `display: none` until a script claims the -masthead, the same handshake the menu button uses, so nothing that cannot -work is ever offered: with scripts blocked the page is still whatever the -machine prefers, which is what the media query decided before any of this. -That is why the alias list is written twice in `src/styles/tokens.css`, once -behind the preference and once behind the attribute, and the preference block -carries `:not([data-theme="light"])` so the override can turn it off. - -The mark is geometry in the markup and not a character. The first draft used -the moon and sun codepoints, both outside the latin and latin-ext subsets the -site ships, so they fell back to whatever the reader's machine had; the second -spelled the press as the mono words `dark` and `light`, which read as a label -rather than a control and sat badly beside the menu button on a phone. Both -are inline SVG now, hairline strokes in `currentColor` at the weight of the -rules around them, and an emoji is still never an icon here. The switch and -the menu button are one square each, the same box and the same edge, 36 to a -side on a pointer and 44 on a thumb, so the pair reads as a pair. - -The mascot is cut out of its background and the code is set in weight rather -than hue, which is why one drawing and one highlighting scheme serve both -grounds. +And the reader can turn it off. The masthead carries one control that writes +`data-theme="light"` onto `` and remembers it; an inline script in the +head restores that choice before the first paint, so a reader who chose light +never sees a flash of the dark page. With scripts blocked the page is dark, +which is what the stylesheet draws with nothing set. + +The mark is geometry in the markup and not a character, inline SVG in +`currentColor`, and it names what the press will do: a dark page offers the +sun. ## Type Two families. **The sans is the author, the mono is the machine.** Anything a command printed is set in mono, without exception. -**Figtree** (OFL, variable) for prose and display. A friendly geometric sans -with enough weight range to carry an 800 headline and a 400 paragraph, and it -ships `latin-ext`, which is a requirement rather than a nicety: the project's -name is Turkish and `ı İ ş ğ ç ö ü` must render. +**Mona Sans** (OFL, variable in weight and width) for prose and display. The +headings and the large figures are set at 106% to 108% of its width with +negative tracking, which is what gives them their weight without going to +black. That is why the width-axis file ships rather than the lighter +weight-only one: without it those headings would render at 100%, and quietly +be a different typeface. It ships `latin-ext`, which is a requirement rather +than a nicety: the project's name is Turkish and `ı İ ş ğ ç ö ü` must render. -**IBM Plex Mono** (OFL) for code, commands, output, labels and the evidence -rail. Ligatures are disabled (`font-variant-ligatures: none`), because a site -about a language's syntax must not silently redraw that syntax. Its true italic -is what comments use. +**JetBrains Mono** (OFL, variable) for code, commands, output, labels and the +bars of code frames. Ligatures are disabled (`font-variant-ligatures: none`), +because a site about a language's syntax must not silently redraw that syntax. +Comments are set in a muted colour rather than in italic, so no italic face +ships. Both are vendored through `@fontsource`, never fetched from a CDN. A site arguing that a program should link only what it uses does not pull webfonts @@ -155,46 +150,44 @@ off a third party. packages and writes `src/generated/fonts.css`, which is the only font import in `src/layouts/Page.astro`. It keeps `woff2` and drops the legacy `woff` that sits beside every `@fontsource` face, and it keeps `latin` and `latin-ext` and drops -`cyrillic`, `cyrillic-ext` and `vietnamese`. Importing the packages' own -stylesheets shipped all of it: two thirds of the font payload was alphabets no -page here contains and a format no browser capable of rendering this site would -request. The `unicode-range` of each face is read out of the package and never -authored, because typing it here would be a transcription of a machine value -that goes stale silently, which is the defect this whole publication is -arranged around. - -Four mono faces ship, and they are the four the stylesheets ask for: 400, 400 -italic, 500 and 600. There is deliberately no 700. `code.css` used to ask for -one for `.tok-rule`, the site's single typographic emphasis, and no 700 face -was ever loaded, so that emphasis was either a synthetic smear or a silent -match to the 600 depending on the reader's browser. It asks for the 600 it -gets. Figtree is variable, so 650 and 800 are real interpolations. +every other alphabet. The `unicode-range` of each face is read out of the +package and never authored, because typing it here would be a transcription of +a machine value that goes stale silently. Two faces are preloaded, the latin sans and the latin mono, because every page -needs both before it can draw a word and they are otherwise discovered a round -trip late. The rest are left to be discovered: preloading a face a page does -not use is the waste this section is about. +needs both before it can draw a word. The `latin-ext` files are left to be +discovered: a page with no Turkish letter on it never asks for them. -Numerals are tabular everywhere so figures in a column compare by eye. The -scale is fluid, ratio 1.25, `--step--2` through `--step-5`. +Numerals are tabular everywhere so figures in a column compare by eye. Body +copy is fixed at 16px; the headings are fluid between a phone and the page +width, `--step-1` through `--step-4`. ## Layout Two containers, declared once in `src/styles/base.css`. -`.wrap` is the page: `min(72rem, 100% - 2 * edge)`, centred. The header, the -footer and every home page section use it, and the home page alternates white -and `--raise` bands across the full width with a hairline between them. +`.wrap` is the page: `min(70rem, 100% - 2 * edge)`, centred, with a 20px edge. +The header and the footer line up with it. `.spread` is the reading page: a `68ch` measure with a `21ch` rail beside it, as named grid lines, so a component can opt into `main`, `rail`, `wide` or `full` without knowing the page. Below 62rem the rail collapses under the -measure. The rail is where provenance lives, at a fixed position, never behind -a hover: a fact whose source you have to go looking for is being presented as -trust rather than as evidence. - -Cards have an 8px radius, 14px on the home page grid. Hairlines separate; -there are no shadows. +measure. The rail is where provenance lives on a lesson, at a fixed position, +never behind a hover. + +A section page is a hero and then sections, each a heading block beside its +evidence in a two-column `.split` that stacks on a phone. The shared pieces +are in `src/styles/site.css`: the type classes, `.sec`, `.split`, `.grid4`, +`.card`, `.code`, `.tabs`, `.chip`, `.tbl`. Anything one page draws stays in +that page's scoped style. + +Cards have a 16px radius, code frames 14px. Hairlines separate. There are two +kinds of lift and no more: a selected tab sits on a one-pixel shadow, and the +two replay panels, the rebuild and the agent race, are lit instruments with a +gradient edge and a drop shadow. A hatch, drawn as a repeating gradient, marks +what a figure leaves out: the part of Crystal's square iyi's hello does not +need, and a module whose body a build skipped. +The masthead is translucent and blurs what scrolls under it. ## Code display @@ -204,7 +197,10 @@ highlighter, into `records/highlight.json`, so what counts as a token is answered by the lexer and never by a grammar this site maintains. The site adds one class after the fact: `.tok-rule`, weight 600, on the keywords that are iyi's and not Crystal's (`module`, `import`, `using`, `pub`, `trait`, `impl`, -`type`, `abstract`). Everything else is weight, not hue. `forall` and `derive` +`type`, `abstract`). Three hues and no more: keywords in `--brand-deep`, +strings in `--ok`, comments in `--mute`, and the rest is ink. The rules keep +their extra weight on top of the hue, so the delta still reads in a printout +or to a reader who cannot tell red from green. `forall` and `derive` are contextual in iyi's parser and reach the page as bare text; emphasising them is a change to the compiler's highlighter, not to this site. @@ -213,7 +209,7 @@ source block shows its path in a header. **Three block types, because they are three kinds of evidence.** A `.source` block is code you could write. A `.console` block is a recording of a real -run: terminal chrome, a mute `$` prompt, and output that is never highlighted +run: a caption bar, a mute `$` prompt, and output that is never highlighted because nothing highlighted it in the terminal. A `.diagnostic` block is a compiler error, caret line preserved exactly, with the rule it cites pulled into a footer that names the SPEC.md section and line the premise is stated @@ -229,9 +225,10 @@ counted from the tree, identical on every machine, and rendered flat and inline by `Measure`, which throws if the key is absent from `facts.json`. A **recorded** number is a second, a byte, a millisecond: a machine, not a -language. It is rendered only by `Stamped`, whose left edge is a ruler of ticks -so the eye reads "this came off an instrument" before it reads any text. The -headline of the card is the ratio, because a ratio survives the machine; the +language. It is rendered only by `Stamped`, a card whose bar reads +`measured · ` and what it is a measurement of, so the eye reads "this came off +an instrument" before it reads the figure. The headline of the card is the +ratio, set at display size, because a ratio survives the machine; the absolute sits in the body beside its spread. The card does not print the box it came off. It did, as a labelled `MACHINE` @@ -259,18 +256,15 @@ a control that cannot do anything is not offered. ## Motion -The site declares motion in four places and no more: the CTA's background -transition, the hamburger's bars, and the chart's two keyframe animations. - -Every one of them is switched off under `@media (prefers-reduced-motion: -reduce)`, in CSS, next to the rule it disables. Not in a script. The preference -was honoured only by a `matchMedia` check inside the chart's client script, -which meant it was honoured only for readers who run scripts, and there was no -`prefers-reduced-motion` query in any stylesheet at all. +Every transition and animation on the site is switched off under +`@media (prefers-reduced-motion: reduce)`, in CSS, by one rule in +`src/styles/base.css`. Not in a script: a preference that only a running +script respects is not respected. Nothing is hidden to satisfy the preference. Reduced motion shows the settled state rather than a faster animation, because a shortened animation would be a -false reading, and the settled state is what the page draws anyway. +false reading, and the settled state is what the page draws anyway. The /agents +race, under the preference, is drawn finished and does not loop. ## Reachability @@ -323,11 +317,19 @@ because the bars are the measurement. ## Imagery -The mascot, and the artifact. There is no photography, no stock, no gradient -and no decorative abstraction. Beyond the mascot, every visual is generated -from something the repository produces: duration figures from the measurement -record, mass figures at true area, and structure diagrams set typographically -in mono with hairline edges. An emoji is never an icon. +The mascot, and the artifact. There is no photography, no stock and no +decorative abstraction. Beyond the mascot, every visual is generated from +something the repository produces: duration figures from the measurement +record, mass figures at true area, a recorded session rendered line by line. + +Two visuals are illustrative, and each page that shows one says so under it. +The home page's rebuild names its modules and its edit for the sake of the +picture; the two durations it runs for, the module count and the ratio are the +measured ones. The agent race, on /agents and in the home page's agents +section, generates its scrolling module bodies from a fixed seed in the +browser; the figures it lands on are read out of `facts.json`, and so is the +line naming the samples they were measured on. Both are in the built HTML +finished, and the script replays them. ## Rendered proof @@ -345,13 +347,15 @@ Captured from the running site in a browser. Recorded because each is a way this specific site could fail. -- No gradient, no glassmorphism, no floating orbs, no icon strip. +- No gradient and no shadow beyond the ones named under Layout. No floating + orbs, no icon strip. - No emoji used as an icon, and no mascot used as one. -- No em-dash and no en-dash in authored copy. A verbatim record, compiler +- No em-dash in authored copy, and an en-dash only between the two ends of a + numeric range set as a figure, `31–43%`. A verbatim record, compiler output, README quotation or sample source, is reproduced exactly, dashes included, because editing a recording to satisfy a house style turns it into a paraphrase. -- No hue in syntax highlighting. +- No hue in syntax highlighting beyond the three named under Code display. - No recorded number outside a measurement card. - No hand-transcribed number anywhere. See `doc/STACK.md`. - No time estimate in any copy, including anything implying how long learning @@ -360,9 +364,12 @@ Recorded because each is a way this specific site could fail. modules; it does not compile what a visitor types, and the page says so. - No figure that only exists once a script has run. Every measurement is drawn by the stylesheet from data in the built HTML, and a script may animate it, - never reveal it. -- No control offered where it cannot act. A menu button and a replay button - appear only once a script has claimed them. + never reveal it. The rebuild, the agent race and the recorded loop are all + in the built HTML, finished; the script replays the first two and folds the + third into tabs. +- No control offered where it cannot act. The menu button, the scheme switch, + every replay button, the loop's tabs and its reader switch appear only once + a script has claimed them. - No colour outside `src/styles/tokens.css`. Both schemes are literals in the base `:root`; the dark scheme only aliases them. A `` and the web manifest take a literal and no variable, so they read the token diff --git a/doc/PLAYGROUND-SERVICE.md b/doc/PLAYGROUND-SERVICE.md index f464696..5d52587 100644 --- a/doc/PLAYGROUND-SERVICE.md +++ b/doc/PLAYGROUND-SERVICE.md @@ -118,10 +118,10 @@ output and compiler diagnostics arriving in the output pane; and a sentence in the output pane on load saying what happens to what you type. **Take the structure and the interaction model. Do not take the look.** This -site has an art direction, `doc/ART-DIRECTION.md`, and it binds: paper -ground, two families and no third, weight-only syntax emphasis with no hue, the -signal colour reserved for losses and caveats, hairlines rather than shadows, -and no sans-serif anywhere. A playground that arrived in another site's visual +site has an art direction, `doc/ART-DIRECTION.md`, and it binds: the page +ground, two families and no third, three hues in syntax and no more, the +accent reserved for what a reader can act on and for losses, hairlines rather +than shadows. A playground that arrived in another site's visual language would be the first page here that did not belong to the publication. ``` diff --git a/doc/screenshots/40-home.png b/doc/screenshots/40-home.png index 1e5ef2f..ee316fc 100644 Binary files a/doc/screenshots/40-home.png and b/doc/screenshots/40-home.png differ diff --git a/doc/screenshots/41-why.png b/doc/screenshots/41-why.png index 16b03ed..bc57ce5 100644 Binary files a/doc/screenshots/41-why.png and b/doc/screenshots/41-why.png differ diff --git a/doc/screenshots/42-learn.png b/doc/screenshots/42-learn.png index bef92fb..f626949 100644 Binary files a/doc/screenshots/42-learn.png and b/doc/screenshots/42-learn.png differ diff --git a/doc/screenshots/43-sample-run.png b/doc/screenshots/43-sample-run.png index dd9db98..03fc133 100644 Binary files a/doc/screenshots/43-sample-run.png and b/doc/screenshots/43-sample-run.png differ diff --git a/doc/screenshots/45-tour.png b/doc/screenshots/45-tour.png index e16d8ef..ba4e38d 100644 Binary files a/doc/screenshots/45-tour.png and b/doc/screenshots/45-tour.png differ diff --git a/package-lock.json b/package-lock.json index c8cc801..bfbc5b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,8 @@ "dependencies": { "@astrojs/mdx": "^7.0.7", "@astrojs/sitemap": "^3.7.4", - "@fontsource-variable/figtree": "^5.3.0", - "@fontsource/ibm-plex-mono": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@fontsource-variable/mona-sans": "^5.3.0", "astro": "^7.2.4" }, "devDependencies": { @@ -132,9 +132,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -151,9 +148,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -170,9 +164,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -189,9 +180,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -516,9 +504,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -532,9 +517,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -548,9 +530,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -564,9 +543,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1199,19 +1175,19 @@ "node": ">=18" } }, - "node_modules/@fontsource-variable/figtree": { + "node_modules/@fontsource-variable/jetbrains-mono": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource-variable/figtree/-/figtree-5.3.0.tgz", - "integrity": "sha512-VRVodD7OiG7apQwE8/2fMjaqpY/a0qRJqL5HtYj8CKIVps+9amaFcdfKmDIf7iqYQx6iPLlArVXAfKeIZmVcNQ==", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" } }, - "node_modules/@fontsource/ibm-plex-mono": { + "node_modules/@fontsource-variable/mona-sans": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", - "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", + "resolved": "https://registry.npmjs.org/@fontsource-variable/mona-sans/-/mona-sans-5.3.0.tgz", + "integrity": "sha512-PkxRB3KI4z9Gjpk7vVqgO3GMSyQuOIIj4TaRDbvbcDj8SzWwI38jvLTQxJM/cTRorhcT7h8qjcllR+qjRtM/Fg==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -1329,9 +1305,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1348,9 +1321,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1367,9 +1337,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1386,9 +1353,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1405,9 +1369,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1424,9 +1385,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1443,9 +1401,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1462,9 +1417,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1481,9 +1433,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1506,9 +1455,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1531,9 +1477,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1556,9 +1499,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1581,9 +1521,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1606,9 +1543,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1631,9 +1565,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1656,9 +1587,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1948,9 +1876,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1967,9 +1892,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1986,9 +1908,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2005,9 +1924,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2024,9 +1940,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2043,9 +1956,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4108,9 +4018,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4131,9 +4038,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4154,9 +4058,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4177,9 +4078,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index 8111933..1cddfa8 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,8 @@ "dependencies": { "@astrojs/mdx": "^7.0.7", "@astrojs/sitemap": "^3.7.4", - "@fontsource-variable/figtree": "^5.3.0", - "@fontsource/ibm-plex-mono": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@fontsource-variable/mona-sans": "^5.3.0", "astro": "^7.2.4" }, "devDependencies": { diff --git a/scripts/budget.json b/scripts/budget.json index 70bf9e0..03a6197 100644 --- a/scripts/budget.json +++ b/scripts/budget.json @@ -20,23 +20,23 @@ ], "grain": 16384, "routes": [ - { "match": "/project/security/", "ceiling": 180224 }, - { "match": "/project/", "ceiling": 180224 }, - { "match": "/project/contributing/", "ceiling": 180224 }, - { "match": "/project/notice/", "ceiling": 180224 }, - { "match": "/project/conduct/", "ceiling": 180224 }, - { "match": "/targets/", "ceiling": 196608 }, - { "match": "/download/", "ceiling": 196608 }, - { "match": "/agents/", "ceiling": 212992 }, - { "match": "/", "ceiling": 278528 }, - { "match": "/404.html", "ceiling": 212992 }, + { "match": "/project/security/", "ceiling": 212992 }, + { "match": "/project/", "ceiling": 212992 }, + { "match": "/project/contributing/", "ceiling": 212992 }, + { "match": "/project/notice/", "ceiling": 212992 }, + { "match": "/project/conduct/", "ceiling": 212992 }, + { "match": "/targets/", "ceiling": 229376 }, + { "match": "/download/", "ceiling": 229376 }, + { "match": "/agents/", "ceiling": 262144 }, + { "match": "/", "ceiling": 294912 }, + { "match": "/404.html", "ceiling": 245760 }, { "match": "/why/", "ceiling": 229376 }, - { "match": "/install/", "ceiling": 196608 }, - { "match": "/learn/", "ceiling": 212992 }, - { "match": "/learn/*/", "ceiling": 212992 }, - { "match": "/playground/", "ceiling": 196608 }, - { "match": "/playground/iyi/*/", "ceiling": 229376 }, + { "match": "/install/", "ceiling": 229376 }, + { "match": "/learn/", "ceiling": 245760 }, + { "match": "/learn/*/", "ceiling": 245760 }, + { "match": "/playground/", "ceiling": 229376 }, + { "match": "/playground/iyi/*/", "ceiling": 262144 }, { "match": "/playground/*/", "ceiling": 294912 } ], - "assets": 589824 + "assets": 638976 } diff --git a/scripts/budget.mjs b/scripts/budget.mjs index c7e7e96..38f2aff 100755 --- a/scripts/budget.mjs +++ b/scripts/budget.mjs @@ -61,7 +61,7 @@ import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const site = resolve(here, ".."); @@ -90,7 +90,7 @@ const files = (dir) => * SITE_BASE set, a page's `href` carries that prefix and joining it onto * `dist/` blind would find nothing, weigh nothing, and pass. A URL this script * built itself from a path on disk has no prefix, hence the conditional. */ -const base = (await import(resolve(site, "astro.config.mjs"))).default.base.replace(/\/*$/, "/"); +const base = (await import(pathToFileURL(resolve(site, "astro.config.mjs")).href)).default.base.replace(/\/*$/, "/"); const asset = (url) => { const path = url.replace(/[?#].*$/, ""); @@ -199,8 +199,13 @@ function weigh(file) { for (const [, set] of html.matchAll(/\bsrcset="([^"]+)"/g)) { for (const candidate of set.split(",")) pictures.add(candidate.trim().split(/\s+/)[0]); } - for (const [, url] of html.matchAll(/]*\brel="preload"[^>]*\bhref="([^"]+)"/g)) { - pictures.add(url); + /* A preloaded image is a picture. A preloaded font is not: it is one of the + * faces the font column below already counts, and adding it here as well + * counted every preloaded face twice. */ + for (const [tag] of html.matchAll(/]*\brel="preload"[^>]*>/g)) { + if (/\bas="font"/.test(tag)) continue; + const href = /\bhref="([^"]+)"/.exec(tag); + if (href) pictures.add(href[1]); } const points = codepoints(html); diff --git a/scripts/contrast.mjs b/scripts/contrast.mjs index 48199df..d62e4c2 100755 --- a/scripts/contrast.mjs +++ b/scripts/contrast.mjs @@ -81,7 +81,7 @@ const value = (name) => { /* ------------------------------------------------------------------------ */ /** The grounds prose sits on, in the light scheme. */ -const GROUNDS = ["paper", "raise", "brand-soft"]; +const GROUNDS = ["paper", "raise", "card", "code", "brand-soft"]; /** * And in the dark one. `src/styles/tokens.css` declares the dark palette under @@ -92,7 +92,7 @@ const GROUNDS = ["paper", "raise", "brand-soft"]; * means two sets of rows, which is the cost of the second scheme being * checkable rather than asserted. */ -const GROUNDS_DARK = ["paper-dark", "raise-dark", "brand-soft-dark"]; +const GROUNDS_DARK = ["paper-dark", "raise-dark", "card-dark", "code-dark", "brand-soft-dark"]; /** * The pairs, each with the floor its role requires. @@ -102,8 +102,11 @@ const GROUNDS_DARK = ["paper-dark", "raise-dark", "brand-soft-dark"]; * at `--step-1` and heavier or larger, so they are large text: 3.0. `--brand` * is only ever used that way; `--brand-deep` exists for the sizes that are * not, which is why the two are separate tokens at all. - * `reverse` is paper on a filled ground: 4.5, because the CTA's label is 17px - * at 600 and that is not large text. + * `reverse` is a label on a filled ground: 4.5, because a button's label is + * 15px at 600 and that is not large text. The red button's label is + * `--on-brand`, white in both schemes; the ink button's is the page ground. + * `status` is the green and the amber a run reports in, as text on every + * ground and on the soft ground of its own chip: 4.5, they are set small. * `boundary` is a focus ring or any non-text control edge: 1.4.11, 3.0. */ const PAIRS = [ @@ -116,12 +119,15 @@ const PAIRS = [ })), ), ...GROUNDS.map((ground) => ({ ink: "brand", ground, role: "display", floor: 3 })), - ...["brand", "brand-deep", "ink"].map((ground) => ({ + { ink: "on-brand", ground: "brand", role: "reverse", floor: 4.5 }, + ...["brand-deep", "ink"].map((ground) => ({ ink: "paper", ground, role: "reverse", floor: 4.5, })), + ...[...GROUNDS, "ok-soft"].map((ground) => ({ ink: "ok", ground, role: "status", floor: 4.5 })), + ...[...GROUNDS, "warn-soft"].map((ground) => ({ ink: "warn", ground, role: "status", floor: 4.5 })), ...["paper", "raise"].map((ground) => ({ ink: "brand", ground, @@ -137,12 +143,15 @@ const PAIRS = [ })), ), ...GROUNDS_DARK.map((ground) => ({ ink: "brand-dark", ground, role: "display", floor: 3 })), - ...["brand-dark", "brand-deep-dark", "ink-dark"].map((ground) => ({ + { ink: "on-brand", ground: "brand-dark", role: "reverse", floor: 4.5 }, + ...["brand-deep-dark", "ink-dark"].map((ground) => ({ ink: "paper-dark", ground, role: "reverse", floor: 4.5, })), + ...[...GROUNDS_DARK, "ok-soft-dark"].map((ground) => ({ ink: "ok-dark", ground, role: "status", floor: 4.5 })), + ...[...GROUNDS_DARK, "warn-soft-dark"].map((ground) => ({ ink: "warn-dark", ground, role: "status", floor: 4.5 })), ...["paper-dark", "raise-dark"].map((ground) => ({ ink: "brand-dark", ground, diff --git a/scripts/fonts.mjs b/scripts/fonts.mjs index 206a739..373824a 100755 --- a/scripts/fonts.mjs +++ b/scripts/fonts.mjs @@ -11,7 +11,7 @@ // cannot read it also cannot read the `@media (prefers-color-scheme)` and // the custom properties this site is built out of. Those files were half // the font payload and zero of the font traffic. -// * `@fontsource/ibm-plex-mono/400.css` and its siblings pull the cyrillic, +// * A `@fontsource` stylesheet and its siblings pull the cyrillic, // cyrillic-ext and vietnamese subsets in with the latin ones. The site is // in English with Turkish proper nouns. Those subsets can never match a // codepoint on any page here. @@ -48,24 +48,22 @@ const require = createRequire(import.meta.url); /** * The faces the stylesheets ask for, and nothing else. * - * The mono list is the set of weights `src/styles` and the components actually - * declare: 400 body code, 400 italic for comments (IBM Plex Mono was chosen - * over the ligature monos for its true italic), 500 for a Crystal keyword and - * the small-caps labels, 600 for a measured value and for `.tok-rule`, the one - * emphasis this site draws. There is deliberately no 700: `code.css` used to - * ask for one, no 700 face was ever loaded, and a weight nothing can render is - * either a synthetic smear or a silent fall back to 600. It now asks for the - * 600 it gets. + * Both families are variable, so one file per subset covers every weight the + * stylesheets name and the in-between weights the headings use (650, 750) are + * real interpolations rather than matches. * - * Figtree is variable, so one file per subset covers 300 through 900 and the - * 650 and 800 the headings use are real interpolations rather than matches. + * Mona Sans ships with its width axis because the display type asks for it: + * the headings and the large figures are set at `font-stretch: 106%` to + * `108%`. The width file is the heavier one, and it is the one the design + * draws with; a `wght`-only file would render those headings at 100% and + * quietly be a different typeface. + * + * JetBrains Mono is upright only. Comments are set in a muted colour rather + * than in italic, so no italic face is asked for and none ships. */ const FACES = [ - { pkg: "@fontsource-variable/figtree", sheet: "wght.css" }, - { pkg: "@fontsource/ibm-plex-mono", sheet: "400.css" }, - { pkg: "@fontsource/ibm-plex-mono", sheet: "400-italic.css" }, - { pkg: "@fontsource/ibm-plex-mono", sheet: "500.css" }, - { pkg: "@fontsource/ibm-plex-mono", sheet: "600.css" }, + { pkg: "@fontsource-variable/mona-sans", sheet: "wdth.css" }, + { pkg: "@fontsource-variable/jetbrains-mono", sheet: "wght.css" }, ]; /** diff --git a/scripts/links.mjs b/scripts/links.mjs index a61cc56..7d76f2e 100755 --- a/scripts/links.mjs +++ b/scripts/links.mjs @@ -47,7 +47,7 @@ import { readFileSync, readdirSync, statSync } from "node:fs"; import { basename, dirname, join, posix, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const site = resolve(here, ".."); @@ -61,7 +61,7 @@ const dist = resolve(site, process.argv[2] ?? "dist"); /* The serving rules are the config's, not this file's. Repeating "the origin * is iyi.dev and the base is /" here would be a second place for them to * disagree, and the config is the one the build obeys. */ -const config = (await import(resolve(site, "astro.config.mjs"))).default; +const config = (await import(pathToFileURL(resolve(site, "astro.config.mjs")).href)).default; const origin = new URL(config.site).origin; const base = config.base.endsWith("/") ? config.base : `${config.base}/`; diff --git a/src/assets/mascot.png b/src/assets/mascot.png index 8572c95..9de2631 100644 Binary files a/src/assets/mascot.png and b/src/assets/mascot.png differ diff --git a/src/components/AgentRace.astro b/src/components/AgentRace.astro new file mode 100644 index 0000000..248a623 --- /dev/null +++ b/src/components/AgentRace.astro @@ -0,0 +1,405 @@ +--- +/** + * The agent race: the same four verbs in two lanes, one grounded in the + * sources and one in `iyi mod context`'s pack, landing on the measured pack + * size and token saving. + * + * The figures are the measured ones, from facts.json, and so is the scope + * line: the samples `bench/context_pack.py` measures, which + * `bench/site_facts.py` reads out of that script. The code scrolling through + * the lanes is illustrative, and the page says so under the panel. + * + * SETTLED IS THE DEFAULT. Both lanes are built finished, every step ticked, + * the result in the foot, so with scripts off this is the result. + * `src/lib/agent-race.ts` takes it back to the start and replays it once the + * reader has scrolled and has it in view, on /agents as anywhere else: never + * at load, where it played out while the reader was still on the heading. + * + * Each step is a link to that step of the recorded loop on /agents, so it + * goes somewhere with scripts off too. + */ +import facts from "../generated/facts.json"; + +interface Props { + /** Where the recorded loop is: `#loop` on /agents, a full path elsewhere. */ + loop: string; + /** The link in the foot that leads to it. */ + hint: string; +} + +const { loop, hint } = Astro.props; +const pack = facts.recorded.context_pack; +const measuredOn = pack.targets.map((target) => target.entry.split("/").at(-1)).join(", "); +--- + +
+
+
+ + + same task, both arms + measured on {measuredOn} + + +
+
+ { + [ + { k: 0, name: "Reading the sources", what: "every body the edit depends on", final: "100%", width: 100 }, + { k: 1, name: "With iyi", what: "iyi mod context: declarations only", final: `${pack.size_low}–${pack.size_high}%`, width: (pack.size_low + pack.size_high) / 2 }, + ].map((lane) => ( +
+ +
+
+ {lane.name} + {lane.what} +
+
+
+ {lane.final} + of the sources' size +
+
+
+ {["read", "check", "fix", "test"].map((label, i) => ( + + + {label} + + ))} +
+
+
+
+ )) + } +
+
+

+ + Same fix. {pack.tokens_low}–{pack.tokens_high}% fewer prompt tokens,{" "} + for no fewer rounds. + + {hint} +

+

Same four verbs in both lanes. Watch what each one has to read.

+
+
+
+ + + + diff --git a/src/components/DurationChart.astro b/src/components/DurationChart.astro index dfb20ec..d93dfec 100644 --- a/src/components/DurationChart.astro +++ b/src/components/DurationChart.astro @@ -2,20 +2,18 @@ /** * The edit loop, drawn at 1:1 real time. * - * This is the site's spine, and it is the project's own idea rather than a - * borrowed one: doc/assets/edit-loop.anim.svg is already an animated bar chart - * drawn by the run that measured it. This component is that chart with the two - * things a README cannot have. + * Each bar takes exactly as long to fill as the build it stands for took, once, + * when it is scrolled to, with a control to run it again. An infinite loop + * would turn a measurement into decoration. * - * First, a control. The README's SVG loops forever because a static image has - * no other way to be seen; a page can wait until it is scrolled to, run once, - * and offer to run again. An infinite loop turns a measurement into decoration. + * The bars are the fastest of the published sessions. The card around this + * chart carries the ratio across all of them, which is the claim: the seconds + * moved between sessions and the ratio held. * - * Second, the spread. README.md publishes three sessions and says to read the - * columns against each other because they pay the same machine together. So - * every bar carries a whisker to its slowest reading, and the headline is the - * ratio, which held across all three. The bar is what varied. The ratio is the - * claim. + * SETTLED IS THE DEFAULT. Each bar's finished width is written into the built + * HTML as an inline custom property and the stylesheet draws it, so with + * scripts off this is a finished bar chart. The script only ever takes the + * width away and gives it back at real speed. * * Nothing here is typed in. Every value comes from bench/site_facts.py. */ @@ -25,213 +23,90 @@ const loop = facts.recorded.edit_loop; const sessions = loop.sessions as Array>; const series = loop.series as Array<{ key: string; label: string }>; -// Scale to the slowest reading of any series in any session, so a whisker can -// never run off the end of its own track. -const ceiling = Math.max( - ...sessions.flatMap((s) => series.map((x) => s[x.key])), -); - const rows = series.map(({ key, label }) => { - const readings = sessions.map((s) => s[key]); - const fast = Math.min(...readings); - const slow = Math.max(...readings); - return { - key, - label, - fast, - slow, - spread: slow > fast, - fastPct: (fast / ceiling) * 100, - slowPct: (slow / ceiling) * 100, - }; + const fast = Math.min(...sessions.map((s) => s[key])); + return { key, label, fast }; }); - -const ratios = loop.ratios as number[]; -const lo = Math.min(...ratios); -const hi = Math.max(...ratios); +const slowest = Math.max(...rows.map((r) => r.fast)); --- -
r.slow)))}> -
- the loop, at the speed you wait at - -
- +
    { rows.map((row) => ( -
  1. +
  2. $ {row.label} -
    -
    - {row.spread && ( -
    - )} -
    - - {row.fast.toFixed(2)} s - + {row.fast.toFixed(2)} s +
    +
  3. )) }
- -

- Bars are the fastest of {sessions.length} published sessions. The hairline - behind each one runs to that session's slowest reading, { - rows.find((r) => r.key === "crystal")!.slow.toFixed(2) - } s at the far end. The ratio held from {lo.toFixed(2)}x to {hi.toFixed(2)}x - while the seconds moved, which is why the ratio is the claim. -

diff --git a/src/components/Measure.astro b/src/components/Measure.astro index eb1829b..abec8a8 100644 --- a/src/components/Measure.astro +++ b/src/components/Measure.astro @@ -32,14 +32,14 @@ if (value === undefined || value === null) { const shown = typeof value === "number" ? value.toLocaleString("en-GB") : value; --- -{shown}{unit ? ` ${unit}` : ""} +{shown}{unit ? ` ${unit}` : ""} diff --git a/src/components/Playground.astro b/src/components/Playground.astro index b6060bc..496132e 100644 --- a/src/components/Playground.astro +++ b/src/components/Playground.astro @@ -47,7 +47,8 @@ import { activeEngine } from "../playground/registry"; import type { Capability } from "../playground/types"; import type { TourStep } from "../playground/samples"; import type { SampleEntry } from "../playground/entry"; -import { runnable, tourSamples, tourSections } from "../playground/samples"; +import { blurbOf, runnable, tourSamples, tourSections } from "../playground/samples"; +import { inlineMarkdown } from "../lib/lessons"; import { markListing } from "../playground/render"; import { highlightedLines, recordedText } from "../lib/highlight"; @@ -91,7 +92,7 @@ const inkLines = /* Every sample opens with a comment saying what it is. That line is the page's * one sentence about the program, so the description cannot drift from the * file. */ -const blurb = source.split("\n")[0].replace(/^#\s*/, "").replace(/\.$/, ""); +const blurb = blurbOf(sample.path); /* The HTML parser drops one newline immediately after a `
` open tag.
  * Every sample begins with a comment, so this costs nothing today; it is here
@@ -120,182 +121,183 @@ const section = tourSections.find((s) => s.steps.some((step) => step.id === samp
 const step = at >= 0 ? `${at + 1} of ${tourSamples.length}` : null;
 ---
 
-
-
-
-

+

+ +
+
+

Playground - {section && `, ${section.title}`}{step && `, ${step}`} + {section && ` · ${section.title}`}{step && ` · ${step}`}

-

{sample.set === "tour" ? sample.id : sample.path}

-

{blurb}.

+

+ { + sample.set === "tour" + ? sample.id + : sample.path.split("/").map((part, i, all) => ( + <>{part}{i < all.length - 1 && <>/} + )) + } +

+

-
- { - runs && ( - - - {/* Only while a run is in flight, and only on a page that has - one: a page with no Run has nothing to stop. */} - - - ) - } +
+ {/* The file and the controls first, so Run is where the eye starts on + any length of program; then the program and what it printed side by + side on a wide screen, the way a reader compares them. */} +
+ + {sample.path} · {runnable(sample) ? `${sample.wasm} · ${Math.round(sample.bytes / 1024)} KB` : "native only"} + + +
- {/* The program travels in the fragment, which is the one part of a URL - a browser never sends anywhere: sharing a program from this page - puts it in the link and not on a server. The codec is - src/playground/share.ts and the ceiling is its own constant. */} - - - {/* Only once there is something to restore. A control that undoes - nothing is a control that does nothing. */} - - - {/* Links, not a select. Each sample is a real URL that resolves before - any script runs, so a lesson's deep link works with scripts off and - survives being bookmarked. */} - -
-
- - {/* Listing first in source order, which is also the order the panes stack - in below the breakpoint, so the program is what a narrow screen shows - first. */} -
-
-
- {sample.path} - {runnable(sample) ? sample.wasm : "native only"} - -
-
-    
- -
-
- - {runs ? "output" : "recorded output"}
+ +
+
+
+        
+ +
+
+ {runs ? "output" : "recorded output"} +
+ { + runs ? ( +
Press Run.
+ ) : ( + +
{sample.nativeStdout}
+
exit {sample.exitCode}, on {entry.recorded.machine}
+
+ ) + } +
+
+ + {/* The same line for the same page once the pane stops holding the file + the record is about. The island only swaps which line is hidden. */} + +
+ +

+ recorded with {entry.recorded.compiler} · {entry.recorded.machine} · {entry.recorded.when.slice(0, 10)} +

+ +

{ - runs ? ( -

Press Run.
- ) : ( - -
{sample.nativeStdout}
-
exit {sample.exitCode}, on {entry.recorded.machine}
-
- ) + runs + ? "You can type in the pane: nothing here compiles, so what you type cannot run, and the page says which bytes it is running the moment the text stops being the recorded file. There is no standard input, no filesystem and no threads on this target, and one sample fails on purpose for exactly that reason." + : "This program does not compile for the browser's target, so this page shows what it printed when the compiler ran it natively, on the machine named above. You can still type in the pane, and the page will say when what is in it is no longer the file the recording is about." } -
-
-

- { - runnable(sample) - ? supports("run") - ? `Run executes ${sample.wasm}, the module the compiler built from this file, in your browser.` - : "The active engine cannot run, so nothing here executes." - : `${sample.reason} What you see is what the program printed when the compiler ran it natively.` - }{" "} - How this works -

- - {/* The same line for the same page once the pane stops holding the file the - record is about. Written here rather than assembled in the browser: it is - copy, it belongs where the rest of the copy is, and the island only - swaps which of the two is hidden. */} - - -
-

- Try it on your machine. Copy the program into{" "} - {basename}, change something, and run it. You can type in the - pane above as well, and the page will say so the moment what is in it - stops being this file, but only a compiler on your own machine can run - what you wrote.

-
-
- - in a shell, after installing - + +

+ { + runnable(sample) + ? supports("run") + ? `Run executes ${sample.wasm}, the module the compiler built from this file, in your browser.` + : "The active engine cannot run, so nothing here executes." + : `${sample.reason} What you see is what the program printed when the compiler ran it natively.` + }{" "} + How this works +

+ +
+
+

Try it on your machine

+

+ Copy the program into {basename}, change something, and + run it. Only a compiler on your own machine can run what you wrote. +

+
+
+
+ sh, after installing + +
+
$ iyi run {basename}
-
$ iyi run {basename}
-
-
+ + {/* After the bench in the source, so a keyboard reaches Run before the + thirty-odd links of the tour; drawn first by the grid. The whole tour, as links rather than a select: each sample is a real + URL that resolves before any script runs, so a lesson's deep link works + with scripts off and survives being bookmarked. */} + - +
- diff --git a/src/components/Stamped.astro b/src/components/Stamped.astro index 066c664..6bfbc24 100644 --- a/src/components/Stamped.astro +++ b/src/components/Stamped.astro @@ -4,140 +4,120 @@ * * These are a machine, not a language, so the headline is the RATIO, because * a ratio survives the machine. The absolute is demoted to the body, where it - * sits next to its spread. The frame is a calibration card and its left edge - * carries instrument ticks, which is the visual signature that separates a - * recorded number from a structural one at a glance. + * sits next to its spread. The card's bar names it as measured and says what + * it is a measurement of, which is what separates a recorded number from a + * structural one at a glance: a structural number is set inline and has no + * card. * - * THE CARD USED TO CARRY ITS OWN PROVENANCE and no longer does. It printed - * MACHINE and PRINTS IT as a labelled rail under every claim, and with six - * recorded facts on the site that was four rows of near-identical furniture - * per card. It is gone from the page. + * The card does not repeat its provenance. The record still has it and is + * still gated: `scripts/facts.mjs` refuses to write `facts.json` when a + * recorded group names no command or no machine, so every figure this + * component renders is one whose box and command are written down in the + * repository, and the colophon on every page names the generator and the + * commit. * - * Where it went, and this is the part that matters: the record still has it - * and is still gated. `scripts/facts.mjs` refuses to write `facts.json` when - * a recorded group names no command or no machine, so every figure this - * component renders is still one whose box and command are written down and - * checkable in the repository. The colophon on every page names the generator - * and the commit. What changed is that the page no longer repeats the box - * beside each number; what did not change is that a number with no box cannot - * reach the page at all, because it cannot reach the record. + * Slots: `figure` draws above the claim (a chart, a pair of squares), + * `action` sits at the right of the bar (a replay control), and the default + * slot is the body under the claim. */ interface Props { /** The claim, machine-independent, stated large. */ claim: string; + /** The rest of the sentence, set under the claim at body weight. */ + tail?: string; /** What the claim is a claim about. */ subject: string; /** Optional band, where a claim held across several readings. */ band?: string; /** Marks a measurement iyi loses. Drives the signal colour. */ outcome?: "win" | "loss" | "flat"; + /** Set the claim in the green a passing run is reported in. */ + accent?: boolean; } -const { claim, subject, band, outcome = "flat" } = Astro.props; ---- +const { claim, tail, subject, band, outcome = "flat", accent = false } = Astro.props; -
- +/* A figure reads at display size; a sentence would not fit at it. */ +const long = claim.length > 26; +const hasFigure = Astro.slots.has("figure"); +const hasBody = Astro.slots.has("default"); +--- -
-
- measured - {subject} -
+
+
+ measured · {subject} + +
-

{claim}

- {band &&

{band}

} + -
+
+

{claim}

+ {tail &&

{tail}

} + {band &&

{band}

}
+ + { + hasBody && ( +
+ +
+ ) + }
diff --git a/src/components/TourGrid.astro b/src/components/TourGrid.astro new file mode 100644 index 0000000..90d167f --- /dev/null +++ b/src/components/TourGrid.astro @@ -0,0 +1,278 @@ +--- +/** + * The tour, one row per section: the section's number, title and blurb, and + * its programs, each described by the program's own first comment line so the + * description cannot drift from the file. Read out of samples/tour/tour.json + * through src/playground/samples.ts, so a step this list offers is a step that + * has a page and a recording. + * + * Two densities. /learn/ shows the programs as chips, because there the tour + * is the second track; /playground/ shows each with its sentence, because + * there it is the whole page. + */ +import { blurbOf, runnable, tourSections } from "../playground/samples"; +import { inlineMarkdown } from "../lib/lessons"; + +interface Props { + variant?: "chips" | "list"; + /** The level the section titles take on the page they sit in. */ + heading?: "h2" | "h3"; +} + +const { variant = "list", heading = "h2" } = Astro.props; +const Title = heading; +const base = import.meta.env.BASE_URL.replace(/\/*$/, "/"); + +const plain = (markdown: string) => markdown.replace(/`/g, ""); + +let n = 0; +const sections = tourSections.map((section, i) => ({ + no: String(i + 1).padStart(2, "0"), + title: section.title, + blurb: section.blurb, + steps: section.steps.map((step) => ({ + n: ++n, + id: step.id, + href: `${base}playground/${step.id}/`, + blurb: blurbOf(step.path), + runs: runnable(step), + })), +})); +--- + +
    + { + sections.map((section) => ( +
  1. +
    + {section.no} +
    + {section.title} + {section.blurb} +
    +
    + {variant === "chips" ? ( +
    + {section.steps.map((step) => ( + + {step.n}{" "} + {step.id} + {!step.runs && <>{" "}native} + + ))} +
    + ) : ( +
      + {section.steps.map((step) => ( +
    1. + + {step.n} + {step.id} + {!step.runs && <>{" "}native} + + {/* Filled by the playground's progress island, in this + browser only, and empty in the HTML. */} + + +
    2. + ))} +
    + )} +
  2. + )) + } +
+ + diff --git a/src/layouts/Page.astro b/src/layouts/Page.astro index bf1574e..d330d27 100644 --- a/src/layouts/Page.astro +++ b/src/layouts/Page.astro @@ -15,19 +15,19 @@ import "../generated/fonts.css"; import "../styles/tokens.css"; import "../styles/base.css"; import "../styles/code.css"; +import "../styles/site.css"; import "../styles/print.css"; /* The two faces every page needs before it can draw a word: the variable sans * the prose is set in and the mono the code is set in, latin in both cases. * They are discovered otherwise only after the stylesheet has been fetched and - * parsed, which is one round trip after the browser could have started them, - * and there was not a single preload in the whole build. The other faces are - * left to be discovered: a page might use no italic and no 600, and preloading - * a face nobody needs is the waste this slice went and removed. `crossorigin` + * parsed, which is one round trip after the browser could have started them. + * The latin-ext files are left to be discovered: a page with no Turkish + * letter on it never asks for them. `crossorigin` * is required even same-origin, because fonts are fetched in CORS mode and a * preload without it fetches the file a second time. */ -import sansLatin from "@fontsource-variable/figtree/files/figtree-latin-wght-normal.woff2"; -import monoLatin from "@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-400-normal.woff2"; +import sansLatin from "@fontsource-variable/mona-sans/files/mona-sans-latin-wdth-normal.woff2"; +import monoLatin from "@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-normal.woff2"; import { Image } from "astro:assets"; import mascot from "../assets/mascot.png"; @@ -165,8 +165,7 @@ const current = (path: string) => here.startsWith(href(path)); - - + {indexable && } @@ -199,18 +198,26 @@ const current = (path: string) => here.startsWith(href(path)); ) } + {/* The stored scheme, applied before the first paint. Dark is what the + stylesheet draws with nothing set, so only a reader who chose light + has anything to restore, and restoring it after the body has painted + is a flash of the wrong page on every load. Inline, because a bundled + module runs too late to prevent that. */} +
-
+
- {/* 28x26 on the page, and it used to be served as the 1200px - original: every visit paid for a hero-sized file to draw a mark - the width of a word. `densities` asks astro:assets for the 1x and - 2x it can actually use. Eager, because it is above the fold on - every page and a lazy mark pops in after the header has drawn. */} + {/* 28px on the page, and served at the size it is drawn: `densities` + asks astro:assets for the 1x and 2x it can actually use. Eager, + because it is above the fold on every page. */} here.startsWith(href(path)); /> iyi - {/* The scheme switch. Hidden until the script claims the masthead, - for the same reason the menu button is: a control that cannot - remember a choice is an ornament, and the page already reads the - machine's preference without one. */} - {/* The two grounds, read out of tokens.css at build time rather than - off the live cascade. `getComputedStyle(root)` for `--paper` - returns whichever scheme is showing, so on a machine that prefers - dark it answers the dark ground and the switch would have written - the wrong chrome colour for the light one. */} - - + {/* Both controls are hidden until the script claims the masthead: a + control that cannot remember a choice or unfold a menu is an + ornament. Their marks are drawn, not typed, so no glyph falls back + to whatever the reader's machine has. */} +
+ + +
@@ -290,52 +287,40 @@ const current = (path: string) => here.startsWith(href(path)); const toggle = document.querySelector(".menu-toggle"); if (header && toggle) { header.dataset.js = "true"; - toggle.addEventListener("click", () => { - const open = header.dataset.open !== "true"; + const set = (open: boolean) => { header.dataset.open = String(open); toggle.setAttribute("aria-expanded", String(open)); + }; + toggle.addEventListener("click", () => set(header.dataset.open !== "true")); + header.querySelector("nav")?.addEventListener("click", (event) => { + if ((event.target as HTMLElement).closest("a")) set(false); }); } - /* The scheme switch. - * - * The page is already whatever the machine prefers, decided in - * tokens.css by a media query that needs nothing from here. All this - * does is write an override onto `` and remember it, which is the - * one thing CSS cannot do for itself. - * - * The `theme-color` metas are rewritten with it. They ship - * media-scoped, which is right until a reader overrules the machine and - * then says the wrong thing about the browser's own chrome: a red bar - * over a white page is the defect those metas were added to fix. - */ + /* The scheme switch. The page is dark unless `` says light, which + * the inline script in the head has already restored from storage. All + * this does is flip that attribute, remember it, and keep the browser + * chrome's colour in step with the page. */ const scheme = document.querySelector(".scheme-toggle"); if (header && scheme) { const root = document.documentElement; const paper = scheme.dataset.paper ?? ""; const paperDark = scheme.dataset.paperDark ?? ""; + const meta = document.querySelector('meta[name="theme-color"]'); const apply = (want: "light" | "dark") => { - root.dataset.theme = want; - for (const meta of document.querySelectorAll('meta[name="theme-color"]')) { - meta.removeAttribute("media"); - meta.content = want === "dark" ? paperDark : paper; - } + if (want === "light") root.dataset.theme = "light"; + else delete root.dataset.theme; + if (meta) meta.content = want === "dark" ? paperDark : paper; + try { + localStorage.setItem("iyi-theme", want); + } catch {} }; - /* What the page is right now: an override if one was stored, the - * machine's answer otherwise. Read rather than assumed, so the first - * press always flips what the reader is looking at. */ - const stored = localStorage.getItem("iyi-theme"); - if (stored === "light" || stored === "dark") apply(stored); + if (root.dataset.theme === "light" && meta) meta.content = paper; scheme.addEventListener("click", () => { - const dark = root.dataset.theme - ? root.dataset.theme === "dark" - : matchMedia("(prefers-color-scheme: dark)").matches; - const want = dark ? "light" : "dark"; - apply(want); - localStorage.setItem("iyi-theme", want); + apply(root.dataset.theme === "light" ? "dark" : "light"); }); } @@ -345,61 +330,58 @@ const current = (path: string) => here.startsWith(href(path));
-
-
- {/* At the foot of every page, so it is below the fold on all of - them and lazy by definition. */} - The iyi mascot, a waving heart - {/* What the thing is, for a reader who landed on a deep page and - scrolled to the bottom. The licence and the copyright used to be - recited here, which made the footer of every page a hand-typed - second copy of `/project/notice/` - the repository's own NOTICE, - published there verbatim and checked byte for byte on every - build. One copy, and a link to it. - - The rule is put as the refusal it is, and in words the hero does - not already use: the hero says "declarations, never their - bodies" and "an interface instead of a repository", so a third - page-furniture copy of those two phrases would read as a - template. What a dependency declares against what it does is - the same rule said once. */} -

- iyi is Turkish for good. A fast, compiled language for developers - and the agents working beside them, built on a single refusal: a - module compiles against what its dependencies declare, never - against what they do. Quick builds and small binaries follow from - it. -

+
+
+
+ + {/* At the foot of every page, so below the fold and lazy. */} + The iyi mascot, a waving heart + iyi + + {/* What the thing is, for a reader who landed on a deep page and + scrolled to the bottom. The licence is one link away, at + `/project/notice/`, rather than a second copy here. */} +

+ iyi is Turkish for good. A fast, compiled + language for developers and the agents working beside them, built + on a single refusal: a module compiles against what its + dependencies declare, never against what they do. Quick builds + and small binaries follow from it. +

+ Install iyi → +
+
- -
-
latest release
-
{release.version}, {release.date}
-
numbers on this site
-
generated by {facts.provenance.generator}
-
measured at
-
{facts.provenance.commit}
-
+

+ + latest release {release.version} · {release.date} + + + numbers on this site generated by {facts.provenance.generator} · measured at {facts.provenance.commit} + +

@@ -407,9 +389,7 @@ const current = (path: string) => here.startsWith(href(path)); Where every copy control's answer is spoken, and the only one on the page. `src/lib/copy.ts` writes into it; nothing else does. It has to be in the document before the text arrives, because a live region inserted - together with its contents announces nothing, which is why it is emitted - here on every page rather than created next to a button. Empty on load, - so it says nothing until somebody presses something. + together with its contents announces nothing. */}

@@ -419,92 +399,118 @@ const current = (path: string) => here.startsWith(href(path)); left: -9999px; } .skip:focus { - left: var(--edge); - top: var(--edge); - background: var(--raise); - padding: 0.5rem 0.8rem; - border: var(--hair) solid var(--ink); - z-index: 10; + left: 16px; + top: 12px; + z-index: 50; + background: var(--ink); + color: var(--paper); + padding: 8px 12px; + border-radius: 8px; } + /* Sticky and translucent: the page scrolls under a frosted bar. */ .masthead { + position: sticky; + top: 0; + z-index: 20; + background: var(--masthead); + backdrop-filter: saturate(1.4) blur(16px); + -webkit-backdrop-filter: saturate(1.4) blur(16px); border-bottom: var(--hair) solid var(--hairline); - background: var(--paper); } - /* Wordmark left, controls right, the nav between them. Every child - * states its own order, because a source order that grew a third child - * left both controls stranded in the middle of a 390px bar with the - * right edge empty, and dropped the switch onto a row of its own below - * the sections the moment the menu opened. */ .masthead-inner { + max-width: calc(var(--page) + 2 * var(--edge)); + margin: 0 auto; + min-height: 60px; + padding: 0 var(--edge); display: flex; align-items: center; - justify-content: flex-start; - gap: 2rem; - min-height: 3.75rem; + gap: 24px; flex-wrap: wrap; } - .masthead .wordmark { - order: 1; - } - .masthead nav { - order: 2; + .wordmark { + display: flex; + align-items: center; + gap: 8px; + color: var(--ink); + font-weight: 800; + font-size: 20px; + letter-spacing: -0.03em; + line-height: 1; + text-decoration: none; } - .masthead .scheme-toggle { - order: 3; + .wordmark:hover { + color: var(--ink); + text-decoration: none; } - .masthead .menu-toggle { - order: 4; + .wordmark :global(img) { + display: block; + width: 28px; + height: auto; } - /* The nav takes the slack, so it and the switch sit together at the - * right edge. Below the breakpoint the nav is a row of its own and the - * switch takes the slack instead. */ .masthead nav { - margin-inline-start: auto; - } - - /* One square for both controls, so the pair reads as a pair: same box, - * same hairline, same radius, the mark centred in it. Both appear on - * the same handshake - `display: none` until the script sets `data-js` - * - because a control that cannot remember a choice or unfold a menu is - * an ornament. They sit after the nav in the source, so a reader - * tabbing the masthead meets the wordmark, then the sections, then the - * controls, and the menu items follow the button that opens them. */ - .masthead .scheme-toggle, - .masthead .menu-toggle { - display: none; - align-items: center; - justify-content: center; - inline-size: 2.25rem; - block-size: 2.25rem; + flex: 1; + } + .masthead nav ul { + display: flex; + gap: 2px; + list-style: none; + margin: 0; padding: 0; - background: none; - border: var(--hair) solid var(--boundary); - border-radius: 6px; - cursor: pointer; + } + .masthead nav a { + display: block; + padding: 8px 11px; + border-radius: 8px; + font-size: 14.5px; + font-weight: 500; color: var(--graphite); + text-decoration: none; + } + .masthead nav a:hover, + .masthead nav a[aria-current="page"] { + color: var(--ink); + background: var(--raise); } - .masthead[data-js="true"] .scheme-toggle { - display: inline-flex; + .masthead-controls { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; } - .masthead .scheme-toggle:hover, - .masthead .menu-toggle:hover { + /* A square with a control's own edge, so the scheme switch reads as a + * button among the links rather than as an ornament beside them. */ + .icon-button { + display: none; + appearance: none; + border: var(--hair) solid var(--boundary); + background: var(--card); + cursor: pointer; + width: 38px; + height: 38px; + padding: 0; + border-radius: 10px; + color: var(--ink); + align-items: center; + justify-content: center; + } + .icon-button:hover { + color: var(--ink); + background: var(--raise); border-color: var(--graphite); } + .masthead[data-js="true"] .scheme-toggle { + display: flex; + } - /* The two marks are drawn at the weight of the rules around them, in - * `currentColor`, and shown one at a time. The mark names the press - * rather than the state, so a light page offers the moon. The dark - * branch is whatever the page actually is, override or preference, - * which is why it is written twice the way tokens.css is. */ .scheme-toggle svg { - inline-size: 1.125rem; - block-size: 1.125rem; + inline-size: 18px; + block-size: 18px; fill: none; stroke: currentColor; stroke-width: 1.5; @@ -512,240 +518,173 @@ const current = (path: string) => here.startsWith(href(path)); stroke-linejoin: round; } - .scheme-toggle .to-light { + /* The mark names the press, not the state: a dark page offers the sun. */ + .scheme-toggle .to-dark { display: none; } - - @media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) .scheme-toggle .to-dark { - display: none; - } - :root:not([data-theme="light"]) .scheme-toggle .to-light { - display: block; - } - } - - :root[data-theme="dark"] .scheme-toggle .to-dark { - display: none; - } - :root[data-theme="dark"] .scheme-toggle .to-light { - display: block; - } :root[data-theme="light"] .scheme-toggle .to-dark { display: block; } + :root[data-theme="light"] .scheme-toggle .to-light { + display: none; + } - /* The hamburger: three bars, shown only below the breakpoint and only - * once the script has claimed the header. */ .bars { display: grid; gap: 4px; - width: 18px; + width: 17px; + color: var(--ink); } - .bars i { display: block; - height: 2px; + height: 1.5px; background: currentColor; border-radius: 1px; transition: transform var(--quick), opacity var(--quick); } - .masthead[data-open="true"] .bars i:nth-child(1) { - transform: translateY(6px) rotate(45deg); + transform: translateY(5.5px) rotate(45deg); } .masthead[data-open="true"] .bars i:nth-child(2) { opacity: 0; } .masthead[data-open="true"] .bars i:nth-child(3) { - transform: translateY(-6px) rotate(-45deg); + transform: translateY(-5.5px) rotate(-45deg); } - /* The bars still cross into an X, they just arrive there. The state is - * what the control means; the sweep is decoration, and this is the one - * transition in the layout. */ - @media (prefers-reduced-motion: reduce) { - .bars i { - transition: none; - } - } - - @media (max-width: 52rem) { + /* Below the breakpoint the sections are a column of their own under the + * bar. With scripts blocked they are simply there; with the script they + * fold behind the menu button. */ + @media (max-width: 959px) { .masthead[data-js="true"] .menu-toggle { - display: inline-flex; - } - - /* A thumb, not a pointer: both controls grow to the 44 by 44 target - * and sit beside each other at the right edge. The 2rem gap the bar - * uses to separate the wordmark from the sections is the wrong - * distance between two buttons that belong together. */ - .masthead-inner { - gap: 0.5rem; - } - - .masthead .scheme-toggle, - .masthead .menu-toggle { - inline-size: 2.75rem; - block-size: 2.75rem; + display: flex; } - .masthead .scheme-toggle { - margin-inline-start: auto; + /* A thumb, not a pointer: both controls grow to the 44 by 44 target. */ + .icon-button { + width: 44px; + height: 44px; } - - /* A row of its own, whether it folds or not: with scripts blocked - * the sections are a plain list under the wordmark, and with the - * script there they unfold into the same place. */ .masthead nav { + order: 3; flex-basis: 100%; - margin-inline-start: 0; + margin: 0 calc(-1 * var(--edge)); + padding: 4px var(--edge) 18px; + border-top: var(--hair) solid var(--hairline); } - .masthead[data-js="true"] nav { display: none; - order: 5; - padding-block: 0.25rem 0.75rem; } - .masthead[data-open="true"] nav { display: block; } - .masthead nav ul { flex-direction: column; - align-items: stretch; - gap: 0.1rem; } - + .masthead nav li + li { + border-top: var(--hair) solid var(--hairline); + } .masthead nav a { - display: block; - padding: 0.6rem 0.7rem; - font-size: var(--step-0); + padding: 13px 0; + font-size: 17px; + color: var(--ink); + border-radius: 0; } - - .nav-github { - margin-inline-start: 0; - margin-block-start: 0.3rem; + .masthead nav a:hover { + background: none; + } + .masthead nav a[aria-current="page"] { + color: var(--brand-deep); + background: none; } } - .wordmark { - display: inline-flex; - align-items: center; - gap: 0.5rem; - text-decoration: none; - font-size: 1.45rem; - font-weight: 800; - letter-spacing: -0.03em; - line-height: 1; - color: var(--ink); - } - - .wordmark:hover { - text-decoration: none; - } - - /* `:global(img)` because the element is rendered by astro:assets rather - * than written here, and Astro's scoping attribute is applied to the - * markup in this file. Scoped by the ancestor, which is this file's. */ - .wordmark :global(img) { - display: block; - height: 1.65rem; - width: auto; - } - - nav ul { - display: flex; - gap: 0.25rem; - list-style: none; - margin: 0; - padding: 0; - flex-wrap: wrap; - align-items: center; - } - - .masthead nav a { - display: inline-block; - padding: 0.45rem 0.7rem; - border-radius: 6px; - font-size: var(--step--1); - font-weight: 500; - color: var(--graphite); - text-decoration: none; - } - - .masthead nav a:hover { - color: var(--ink); - background: var(--raise); - } - - .masthead nav a[aria-current="page"] { - color: var(--brand-deep); - } - - .nav-github { - margin-inline-start: 0.5rem; - } - - .nav-github a { - border: var(--hair) solid var(--hairline); + @media (prefers-reduced-motion: reduce) { + .bars i { + transition: none; + } } .colophon { - margin-block-start: 5rem; - padding-block: 2.5rem 3rem; + margin-top: clamp(80px, 11vw, 120px); border-top: var(--hair) solid var(--hairline); background: var(--raise); } - .colophon-inner { - display: grid; - gap: 2.5rem; - grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.8fr) minmax(0, 1fr); - align-items: start; + max-width: calc(var(--page) + 2 * var(--edge)); + margin: 0 auto; + padding: clamp(40px, 6vw, 64px) var(--edge) 28px; + display: flex; + flex-direction: column; + gap: clamp(32px, 5vw, 48px); } - - @media (max-width: 52rem) { - .colophon-inner { - grid-template-columns: minmax(0, 1fr); - } + .colophon-top { + display: flex; + flex-wrap: wrap; + gap: 32px 56px; + justify-content: space-between; + align-items: flex-start; } - .colophon-brand { + flex: 1 1 340px; + max-width: 480px; display: flex; - gap: 1.1rem; - align-items: flex-start; + flex-direction: column; + gap: 16px; } - - .colophon-brand :global(img) { - flex: none; - width: 4.5rem; - height: auto; + .wordmark--large { + font-weight: 750; + font-stretch: 108%; + font-size: 26px; + } + .wordmark--large :global(img) { + width: 40px; } - .colophon-brand p { - font-size: var(--step--1); - color: var(--graphite); - max-width: 42ch; margin: 0; + font-size: 15px; + line-height: 1.6; + color: var(--graphite); + } + .colophon-brand strong { + color: var(--ink); + font-weight: 600; + } + .colophon-links { + display: grid; + grid-template-columns: repeat(2, minmax(140px, auto)); + gap: 12px 48px; } - .colophon-links ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; flex-direction: column; - gap: 0.35rem; + gap: 12px; } - .colophon-links a { - font-size: var(--step--1); - color: var(--graphite); + font-size: 15px; + color: var(--ink); + text-decoration: none; } - .colophon-links a:hover { - color: var(--ink); + color: var(--brand-deep); } - - .colophon-evidence { + .colophon-bottom { margin: 0; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 8px 24px; + padding-top: 20px; + border-top: var(--hair) solid var(--hairline); + font-family: var(--font-mono); + font-size: 12px; + color: var(--mute); + } + .colophon-bottom a { + text-decoration: none; } diff --git a/src/lib/agent-race.ts b/src/lib/agent-race.ts new file mode 100644 index 0000000..c389cb4 --- /dev/null +++ b/src/lib/agent-race.ts @@ -0,0 +1,133 @@ +/** + * The agent race's replay: the same four verbs in two lanes, one reading the + * sources and one reading `iyi mod context`'s pack. + * + * The race is illustrative, and every page that shows it says so under it. + * It plays once when it is seen and again on Replay, never on a loop. The + * module bodies scrolling through its lanes are generated here from a fixed + * seed; the numbers it lands on are the measured ones, which the component + * hands it through data attributes read out of facts.json. + * + * The steps under each lane are links to the recorded loop. The /agents page + * opens the matching step in place; anywhere else the link is followed. + */ + +import { playWhenSeen, reducedMotion } from "./play-when-seen"; + +export function wireAgentRace(race: HTMLElement) { + const modules = Number(race.dataset.modules); + const packLow = Number(race.dataset.packLow); + const packHigh = Number(race.dataset.packHigh); + const packMid = (packLow + packHigh) / 2; + + const WORDS = ["http", "json", "strings", "io", "path", "time", "fmt", "hash", "list", "map", "log", "bytes", "math", "sort", "sync", "fs", "uri", "csv", "toml", "term", "env", "cli", "color", "diff", "test", "net", "rand", "os"]; + const TYPES = ["String", "Int32", "Bool", "Bytes", "Float64", "Array(String)"]; + let seed = 11; + const rnd = () => { + seed = (seed * 9301 + 49297) % 233280; + return seed / 233280; + }; + const pick = (a: T[]) => a[Math.floor(rnd() * a.length)]; + + const srcLines: string[] = []; + const packLines: string[] = []; + for (let i = 0; i < modules; i++) { + const w = i === 0 ? "app" : i === 1 ? "greeter" : WORDS[(i - 2) % WORDS.length]; + if (i === 0) { + srcLines.push("module app", "import app/greeter::*", "pub def main : Int32", ' puts polit("Türkiye")', " 0", "end"); + packLines.push("pub def main : Int32"); + continue; + } + if (i === 1) { + srcLines.push("module app/greeter", "pub trait Greet", " abstract def greet : String", "end", "pub def polite(name : String) : String", ' "#{greeting}, #{name}."', "end", "pub def shout(name : String) : String", ' "#{greeting.upcase}, #{name.upcase}!"', "end", "def greeting : String", ' "Hello"', "end"); + packLines.push("def polite(name : String) : String", "def shout(name : String) : String", "trait Greet · abstract def greet : String"); + continue; + } + srcLines.push(`module lib/${w}`); + const count = 2 + Math.floor(rnd() * 3); + for (let f = 0; f < count; f++) { + const fn = `${w}_${pick(["parse", "read", "build", "each", "merge", "check", "format", "split"])}`; + const sig = `def ${fn}(x : ${pick(TYPES)}) : ${pick(TYPES)}`; + packLines.push(sig); + srcLines.push(`pub ${sig}`); + const lines = 2 + Math.floor(rnd() * 5); + for (let b = 0; b < lines; b++) { + srcLines.push(` ${pick(["acc = [] of String", "x.each { |v| acc << v }", "return default unless x", "buf = IO::Memory.new", `check!(x, "${w}")`, "result = step(acc, x)", `raise Error.new("bad ${w}") if x.nil?`, "cache[x] ||= compute(x)"])}`); + } + srcLines.push("end"); + } + } + + const MARKS = [0, 0.66, 0.8, 0.92]; + const lanes = [...race.querySelectorAll("[data-lane]")]; + const foot = race.querySelector("[data-foot]"); + const dot = race.querySelector("[data-dot]"); + let t = 1; + + const tick = () => { + const done = t >= 1; + const read = Math.min(1, t / 0.55); + const arms = [ + { pct: read * 100, lines: srcLines, keep: 12, label: `${Math.round(read * 100)}%` }, + { pct: read * packMid, lines: packLines, keep: 4, label: read >= 1 ? `${packLow}–${packHigh}%` : `${Math.round(read * packMid)}%` }, + ]; + arms.forEach((arm, k) => { + const lane = lanes[k]; + if (!lane) return; + const n = Math.floor(read * arm.lines.length); + lane.dataset.full = String(k === 0 && arm.pct > 85); + const text = lane.querySelector(".lane-text"); + if (text) text.innerHTML = arm.lines.slice(Math.max(0, n - arm.keep), n).map((x) => `
${x.replace(/&/g, "&").replace(//g, ">")}
`).join(""); + const num = lane.querySelector(".lane-num b"); + if (num) num.textContent = arm.label; + const bar = lane.querySelector(".lane-bar i"); + if (bar) bar.style.width = `${arm.pct}%`; + lane.querySelectorAll("[data-goto]").forEach((b, i) => { + const on = i === 0 ? read >= 1 : t >= MARKS[i]; + const active = !on && (i === 0 ? t > 0 : t >= MARKS[i] - 0.12 && t >= MARKS[i - 1]); + b.dataset.state = on ? "done" : active ? "active" : "idle"; + }); + }); + race.dataset.done = String(done); + if (dot) dot.dataset.done = String(done); + if (foot) foot.dataset.done = String(done); + }; + + /* Under reduced motion there is nothing to replay, so the control is not + * offered: the race stays at its finished state. */ + if (reducedMotion()) { + t = 1; + tick(); + return; + } + race.dataset.js = "true"; + + /* One run, then the finished result stays. A replay that loops reads as + * decoration; Replay runs it again. */ + let timer = 0; + let played = false; + const play = () => { + played = true; + window.clearInterval(timer); + t = 0; + tick(); + timer = window.setInterval(() => { + t = Math.min(1, t + 0.011); + tick(); + if (t >= 1) window.clearInterval(timer); + }, 70); + }; + + race.querySelector("[data-replay]")?.addEventListener("click", play); + + /* Once the reader has scrolled and the whole of it is in the upper part of + * the screen. Either alone was not enough on /agents, where it sits under + * the heading: at load it started with its numbers below the fold, and on a + * screen tall enough to hold it whole it played out while the reader was + * still on the heading. A page restored part way down counts as scrolled. */ + const playOnce = () => { + if (!played) play(); + }; + if (scrollY > 0) playWhenSeen(race, playOnce); + else addEventListener("scroll", () => playWhenSeen(race, playOnce), { once: true, passive: true }); +} diff --git a/src/lib/agents-page.ts b/src/lib/agents-page.ts new file mode 100644 index 0000000..59de8cb --- /dev/null +++ b/src/lib/agents-page.ts @@ -0,0 +1,96 @@ +/** + * The /agents page's interactions: the recorded loop's step and reader + * switches, and the rebuild question's edit switch. The race at the top wires + * itself, in `agent-race.ts`; this module only opens the step one of its links + * names, in place rather than by jumping to the loop. + * + * Every panel the loop and the rebuild question can show is in the built + * HTML, so with scripts off the page is the whole recording laid out in + * order. This module only hides the panels that are not selected, and the + * controls that select them are hidden until it runs. + */ + +import { reducedMotion } from "./play-when-seen"; + +const store = (key: string, value?: string): string | null => { + try { + if (value === undefined) return localStorage.getItem(key); + localStorage.setItem(key, value); + } catch {} + return null; +}; + +/* The loop ---------------------------------------------------------------- */ + +function wireLoop(box: HTMLElement): (step: number) => void { + box.dataset.js = "true"; + const steps = [...box.querySelectorAll("[data-step-panel]")]; + const tabs = [...box.querySelectorAll("[data-step]")]; + const readers = [...box.querySelectorAll("[data-reader]")]; + const next = box.querySelector("[data-next]"); + const readerNote = box.querySelector("[data-reader-note]"); + + let step = 0; + let reader = store("iyi-reader") === "agent" ? "agent" : "person"; + + const render = () => { + tabs.forEach((tab, i) => tab.setAttribute("aria-selected", String(i === step))); + steps.forEach((panel, i) => (panel.hidden = i !== step)); + readers.forEach((b) => b.setAttribute("aria-checked", String(b.dataset.reader === reader))); + box.dataset.reader = reader; + if (readerNote) readerNote.textContent = reader === "person" ? "rendered for you" : "exactly what the agent receives"; + if (next) next.textContent = step === steps.length - 1 ? "Start over" : "Next step →"; + }; + + box.addEventListener("click", (event) => { + const b = (event.target as HTMLElement).closest("button"); + if (!b) return; + if (b.dataset.step !== undefined) step = Number(b.dataset.step); + else if (b.dataset.reader !== undefined) { + reader = b.dataset.reader; + store("iyi-reader", reader); + } else if (b.hasAttribute("data-next")) step = (step + 1) % steps.length; + else return; + render(); + }); + + render(); + return (to: number) => { + step = to; + render(); + box.scrollIntoView({ behavior: reducedMotion() ? "auto" : "smooth", block: "start" }); + }; +} + +/* The rebuild question ---------------------------------------------------- */ + +function wireDiff(box: HTMLElement, options: HTMLElement) { + options.dataset.js = "true"; + box.dataset.js = "true"; + const buttons = [...options.querySelectorAll("[data-edit]")]; + const panels = [...box.querySelectorAll("[data-edit-panel]")]; + const pick = (edit: string) => { + buttons.forEach((b) => b.setAttribute("aria-checked", String(b.dataset.edit === edit))); + panels.forEach((p) => (p.hidden = p.dataset.editPanel !== edit)); + }; + options.addEventListener("click", (event) => { + const b = (event.target as HTMLElement).closest("[data-edit]"); + if (b?.dataset.edit) pick(b.dataset.edit); + }); + pick(buttons[0]?.dataset.edit ?? "body"); +} + +export function wireAgentsPage() { + const loop = document.querySelector("#loopbox"); + const goStep = loop ? wireLoop(loop) : () => {}; + const diff = document.querySelector("#diffbox"); + const options = document.querySelector("#diffopts"); + if (diff && options) wireDiff(diff, options); + /* The race's step links lead to the loop; here they open that step. */ + document.querySelector("[data-agent-race]")?.addEventListener("click", (event) => { + const step = (event.target as HTMLElement).closest("[data-goto]"); + if (!step) return; + event.preventDefault(); + goStep(Number(step.dataset.goto)); + }); +} diff --git a/src/lib/play-when-seen.ts b/src/lib/play-when-seen.ts new file mode 100644 index 0000000..bc9d1a1 --- /dev/null +++ b/src/lib/play-when-seen.ts @@ -0,0 +1,54 @@ +/** + * Play a figure once, when the reader can see it. + * + * Two ways of deciding that, because the figures sit in two places. + * + * A figure in the middle of a page waits until the whole of it is on screen, + * in the upper two thirds, so it is not over by the time the eye gets there. + * A figure taller than that band can never be wholly inside it, so for one of + * those it is enough that it fills the band; without that, a tall figure on a + * short screen never played and stayed empty. + * + * A figure in the first screen, the home page's rebuild, passes `threshold` + * instead and plays as soon as that share of it shows, which on load is at + * once. + * + * The caller owns the Replay control and calls `play` itself. + */ +export function playWhenSeen(element: Element, play: () => void, options: { threshold?: number } = {}): void { + const { threshold } = options; + const seen = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + let ready: boolean; + if (threshold !== undefined) { + ready = entry.intersectionRatio >= threshold; + } else { + const band = entry.rootBounds?.height ?? innerHeight; + const whole = entry.intersectionRatio >= 0.999; + const fills = entry.boundingClientRect.height >= band && entry.intersectionRect.height >= band * 0.9; + ready = whole || fills; + } + if (ready) { + seen.disconnect(); + play(); + return; + } + } + }, + /* A callback comes only when the ratio crosses a threshold. A figure that + * fills the band stops growing at band / height of itself, short of 1, so + * with coarse steps the moment it filled could fall between two of them: + * the /agents race on a phone, 825 px tall in a 566 px band, sat at 0.69 + * between 0.5 and 0.75 and never played. Steps of a hundredth leave no such + * gap for any figure up to ten bands tall. */ + threshold !== undefined + ? { threshold } + : { threshold: Array.from({ length: 101 }, (_, i) => i / 100), rootMargin: "0px 0px -33% 0px" }, + ); + seen.observe(element); +} + +/** Whether the reader asked for less motion. */ +export const reducedMotion = (): boolean => matchMedia("(prefers-reduced-motion: reduce)").matches; diff --git a/src/pages/agents.astro b/src/pages/agents.astro index b21e62d..636e8d3 100644 --- a/src/pages/agents.astro +++ b/src/pages/agents.astro @@ -1,53 +1,44 @@ --- /** - * The agent claim, as a transcript. + * /agents: the loop an agent runs against iyi, as a recording. * - * The site's first line says iyi is a language for people and their agents. - * Every other claim here is measured or replayed; that one used to be four - * paragraphs about verbs, and prose about a compiler is a claim about a - * compiler rather than a reading of one. So this page shows the loop instead - * of describing it, and shows nothing else. + * Three things on the page. The agent race at the top, which replays the + * measured result as an animation and says under it that it is a replay. The + * loop itself, four steps, every command and every byte it printed read out + * of records/agent.json, which `npm run record:agent` wrote against a real + * compiler. And the rebuild question, answered by the recorded `iyi mod diff` + * of the same project. * - * NOTHING ON THIS PAGE IS TYPED. Every command line is the argv - * `scripts/record-agent.mjs` spawned, every body is the bytes that came back, - * every exit code is the status the process gave, and the listing is - * transcluded out of `records/agent/` with the compiler's own lexer colouring - * it. The recorder refuses to write its record when a verb's verdict moves, - * when `check` stops offering an edit, when a body-only change starts moving - * an interface, or when the answer over MCP stops matching the answer in a - * shell. So a page that renders is a page whose frames still hold. - * - * IT IS SHORT ON PURPOSE. The first draft explained each frame twice, once - * before it and once after, and carried a second copy of the suggested edit as - * a field list beside the JSON that already contained it. A transcript that - * needs a paragraph per line is not evidence, it is an argument with exhibits. - * One sentence per frame, and the frame says the rest. + * Nothing is typed here that the recording or facts.json holds. The person + * reading of each frame is derived from the frame's own bytes below, so the + * two readings of one frame cannot say different things. */ +import { Image } from "astro:assets"; import Page from "../layouts/Page.astro"; -import Frame from "../components/Frame.astro"; -import Sample from "../components/Sample.astro"; -import Stamped from "../components/Stamped.astro"; -import Measure from "../components/Measure.astro"; import facts from "../generated/facts.json"; import record from "../../records/agent.json"; -import thesis from "../generated/thesis.json"; +import mascot from "../assets/mascot.png"; +import Stamped from "../components/Stamped.astro"; +import AgentRace from "../components/AgentRace.astro"; import { SPELLED } from "../lib/lessons"; const base = import.meta.env.BASE_URL; const pack = facts.recorded.context_pack; +/* What the pack and the tokens were measured on, as `bench/context_pack.py` + * declares it: each sample, and the modules whose sources it is weighed + * against. */ +const and = new Intl.ListFormat("en", { type: "conjunction" }); +const measuredOn = pack.targets.map((target) => ({ + file: target.entry.split("/").at(-1), + imports: and.format(target.imports.map((path) => path.replace(/^samples\/iyi\/|\.iyi$/g, ""))), +})); +const taskFile = pack.task.split("/").at(-1); const provenance = record.recorded; -const wire = record.wire; - -/** Which stream a verb answers on. JSON gives it as a string; the component - * takes only these two, and a record naming a third is a record this page - * cannot render honestly. */ -type Stream = "stdout" | "stderr"; -const frames = Object.fromEntries(record.frames.map((f) => [f.id, f])); +type Frame = (typeof record.frames)[number]; -/** A frame by id, with the stream it answered on narrowed. */ -const frame = (id: string) => { - const found = frames[id]; +const frame = (id: string): Frame & { answer: string } => { + const found = record.frames.find((f) => f.id === id); if (!found) { throw new Error( `agents: records/agent.json has no frame "${id}". It has ` + @@ -56,437 +47,864 @@ const frame = (id: string) => { ); } if (found.payload !== "stdout" && found.payload !== "stderr") { - throw new Error( - `agents: frame "${id}" says it answers on "${found.payload}", and a ` + - `recording of a process has two streams. Re-record.`, - ); + throw new Error(`agents: frame "${id}" answers on "${found.payload}", which is not a stream`); } - const payload = found.payload as Stream; - return { ...found, payload, answer: found[payload] }; + return { ...found, answer: found[found.payload as "stdout" | "stderr"] }; +}; + +const projectFile = (name: string) => { + const found = record.project.find((file) => file.name === name); + if (!found) throw new Error(`agents: records/agent/${name} is not in the recording`); + return found; +}; + +const consumer = projectFile("app.iyi"); +const before = consumer.text.replace(/\n$/, "").split("\n"); +const after = record.repaired.text.replace(/\n$/, "").split("\n"); + +const [edit] = record.suggested; +if (!edit) throw new Error("agents: the recording carries no suggested edit to show"); + +const importLine = before.findIndex((line) => line.startsWith("import ")) + 1; +if (importLine === 0) throw new Error("agents: app.iyi imports nothing, so there is no surface to ground"); + +/* The person's reading of each frame, derived from the frame. */ +type Row = { key: string; text: string; tone?: "error" | "ok" }; + +const firstLine = (s: string) => s.split("\n")[0]; + +const groundRows = (answer: string): Row[] => { + const pack = JSON.parse(answer) as { + imports: Array<{ + import: string; + api: { + interface_hash: string; + functions: Array<{ rendered: string }>; + types: Array<{ name: string; kind: string; methods: Array<{ rendered: string }> }>; + }; + }>; + }; + return pack.imports.flatMap((entry) => [ + { key: "import", text: `${entry.import} · interface ${entry.api.interface_hash.slice(0, 8)}` }, + ...entry.api.functions.map((f) => ({ key: "def", text: f.rendered.replace(/^def /, "") })), + ...entry.api.types.map((t) => ({ + key: t.kind, + text: `${t.name} · ${t.methods.map((m) => m.rendered).join(" · ")}`, + })), + ]); +}; + +const checkRows = (answer: string): Row[] => { + const verdict = JSON.parse(answer) as Array<{ + line: number; + column: number; + message: string; + suggested_edit?: { line: number; column: number; size: number; replacement: string }; + }>; + return verdict.flatMap((entry) => { + const rows: Row[] = [ + { + key: `${entry.line}:${entry.column}${entry.suggested_edit ? " error" : ""}`, + text: firstLine(entry.message), + tone: entry.suggested_edit ? "error" : undefined, + }, + ]; + if (entry.suggested_edit) { + const s = entry.suggested_edit; + rows.push({ + key: "fix", + text: `replace ${s.size} characters at ${s.line}:${s.column} with '${s.replacement}'`, + tone: "ok", + }); + } + return rows; + }); }; -/** The consumer being edited, which is the only listing this page shows. */ -const consumer = record.project.find((file) => file.name === "app.iyi"); -if (!consumer) { - throw new Error("agents: records/agent/app.iyi is not in the recording"); -} +const plainRows = (key: string, answer: string, silent: string, tone?: "ok"): Row[] => + answer.trim() === "" ? [{ key, text: silent }] : [{ key, text: answer.trim(), tone }]; + +const run = (id: string, label: string, rows: (answer: string) => Row[]) => { + const f = frame(id); + return { command: f.command, label, stream: f.payload, exit: f.exitCode, raw: f.answer, rows: rows(f.answer) }; +}; + +const steps = [ + { + title: "Ground the edit", + source: before, + mark: { line: importLine, tone: "warn" }, + runs: [run("ground", "the grounding pack", groundRows)], + }, + { + title: "Ask for the verdict", + source: before, + mark: { line: edit.line, tone: "err" }, + runs: [run("check", "the compiler's verdict", checkRows)], + }, + { + title: "Apply it, and confirm", + source: after, + mark: { line: edit.line, tone: "ok" }, + runs: [ + run("fix", "the repair", (a) => plainRows("fixed", a, "", "ok")), + run("clean", "green, and silent about it", (a) => + plainRows("exit 0", a, "No output. The verdict is the exit code."), + ), + ], + }, + { + title: "Re-run only what the change reaches", + source: after, + mark: null, + runs: [ + run("affected-hit", "a module the test imports", (a) => plainRows("ran", a, "")), + run("affected-miss", "a module nothing imports", (a) => plainRows("skipped", a, "")), + ], + }, +]; + +/* The rebuild question. The consumers are the files in the recorded project + * that import the module the edit is in, read out of their own text. */ +const edited = projectFile("app/greeter.iyi"); +const editedModule = /^module (\S+)/m.exec(edited.text)?.[1]; +if (!editedModule) throw new Error("agents: app/greeter.iyi declares no module"); +const consumers = record.project + .filter((file) => file !== edited && new RegExp(`^import ${editedModule}\\b`, "m").test(file.text)) + .map((file) => /^module (\S+)/m.exec(file.text)?.[1] ?? file.name); + +const verdictOf = (answer: string) => answer.trim().split("\n").at(-1) ?? ""; +const diffs = [ + { key: "body", label: "Change a body", frame: frame("diff-body") }, + { key: "api", label: "Change a declaration", frame: frame("diff-api") }, +].map((d) => { + const moved = /interface\s+changed/.test(d.frame.answer); + return { ...d, moved, verdict: verdictOf(d.frame.answer), output: d.frame.answer.replace(/\n+[^\n]*\n?$/, "") }; +}); --- -
-
-

For agents, and for you

-

A language with two first-class readers.

-

- iyi treats an agent as a reader of code beside the person writing it. - That is a decision in the language rather than a layer over it: the - rules that make a build read declarations instead of bodies are the - rules that let a model read an interface without the repository. -

-

- The design record states what that takes, and this page is the - recording of it working. -

-
-
- -
-
-

What it takes, and which of it is a rule

-

- Four properties, in {thesis.provenance.source}'s own words. Numbers{" "} - {thesis.held.numbers.join(" and ")} are marked because the record says - iyi holds them {thesis.held.how}, and names{" "} - {thesis.held.rules.map((rule, i) => ( - <>{i === 0 ? "" : i === thesis.held.rules.length - 1 ? " and " : ", "}{rule} - ))}{" "} - as the rules that do it. The other two are tool surface, which is what - the session below exercises. +

+
+
+

+ + Agent experience (AX) +

+

Your agent reads the interface, not the repository.

+

+ When it's wrong and the compiler knows the fix, the verdict carries + it. iyi holds this by language rule: a module compiles against what + its dependencies declare, so a model can read an interface without + the repository. +

+
+ + +

+ A replay of the measured result (method below). Code shown is + illustrative; the recorded session follows.

-
    +
+ +
+
+ + recorded with {provenance.compiler.split(" (")[0]} · {provenance.commit.slice(0, 9)} + +

The loop, recorded.

+

+ {record.project.length} files, one mistake, the compiler's own verbs. + The same verbs answer over MCP, byte for byte. +

+
+ +
+
+ { + steps.map((step, i) => ( + + )) + } +
+ { - thesis.properties.map((property) => ( -
  • -

    {property.name}

    -

    {property.text}

    - {thesis.held.numbers.includes(property.number) && ( -

    held by rule

    - )} -
  • + steps.map((step, i) => ( +
    +

    + 0{i + 1} · {step.title} +

    +
    records/agent/{consumer.name} · {step.source.length} lines
    +
    + {step.source.map((line, n) => ( +
    + {n + 1} + {line || " "} +
    + ))} +
    + {step.runs.map((r) => ( +
    +
    + + $ + {r.command} + + + + {r.label} + {r.stream} + exit {r.exit} + +
    +
    + {r.rows.map((row) => ( +
    + {row.key} + {row.text} +
    + ))} +
    +
    {r.raw.trim() || "(no output)"}
    +
    + ))} +
    )) } - -

    {thesis.provenance.source}, §{thesis.provenance.section}

    -
    -
    - -
    -
    -

    The loop, recorded

    -

    - An agent is handed {SPELLED[record.project.length]} modules with one - mistake in them and takes them to green with the compiler's own verbs. - Below is what those verbs answered, in the order they answered, run by{" "} - {provenance.command}. The recorder refuses to publish a - frame whose verdict moved, so a page that loads is a page whose session - still runs. -

    -

    - The file being edited, committed broken on purpose. Its dependency - exports two functions and keeps a third to itself, which is why nothing - below mentions the third. -

    - - -
      -
    1. -

      Ground the edit

      -

      - Every import's exported surface as data, with its doc comments and - the interface_hash they are keyed by. No bodies, and - read without compiling the file, so it is available while the - program is still wrong. -

      - -
    2. - -
    3. -

      Ask for the verdict

      -

      - Each error is a record, and where the compiler knows the fix it - carries suggested_edit with the exact span to replace. - On {frame("check").payload}, because an error is an - error whatever its format. -

      - -
    4. - -
    5. -

      Apply it, and confirm

      -

      - fix applies the compiler's own edits and recompiles - after each, so no round is spent on a repair the compiler already - knows. The confirmation prints nothing: the verdict is the exit - code. -

      - - -
    6. - -
    7. -

      Re-run only what the change reaches

      -

      - A test is affected exactly when the changed file is in its - transitive import closure, parsed rather than compiled. So the skip - is a fact and not an optimization that might be wrong. -

      -
      - - -
      -
    8. -
    -
    -
    - -
    -
    -

    Does the edit reach anyone?

    -

    - An agent working in someone else's repository needs to know whether an - edit affects consumers or stays internal. Two edits to the same file, - one artifact each, read rather than rebuilt. -

    -
    - - -
    -
    -
    - -
    -
    -

    The servers are in the binary

    -

    - There is nothing to install and no second implementation to keep in - step. iyi lsp speaks the Language Server Protocol to an - editor and iyi mcp serves{" "} - {wire.tools.map((tool, i) => ( - <>{i === 0 ? "" : i === wire.tools.length - 1 ? " and " : ", "}{tool} - ))}{" "} - as Model Context - Protocol tools to a harness, both over stdio, and both are shells - around the same front end a build uses. So there is no agent mode and - no forked behavior: same verb, same file, two doors. -

    -
    -
    -
    - - iyi check -f json app.iyi -
    -
    {wire.shell}
    -
    -
    -
    - - tools/call, name: check + +
    +
    +
    + Read as + + +
    + rendered for you
    -
    {wire.mcp}
    +
    +
    + +
    +
    +

    The servers are in the binary.

    +

    + There is nothing to install and no second implementation to keep in + step. iyi lsp speaks the Language Server Protocol to an + editor and iyi mcp serves{" "} + {record.wire.tools.map((tool, i) => ( + <>{i === 0 ? "" : i === record.wire.tools.length - 1 ? " and " : ", "}{tool} + ))}{" "} + as Model Context Protocol tools to a harness, both over stdio, and + both are shells around the same front end a build uses. So there is + no agent mode and no forked behavior: same verb, same file, two + doors. +

    +
    -
    -
    identical
    -
    {String(wire.identical)}
    -
    bytes
    -
    {wire.bytes}
    -
    +

    identical: {String(record.wire.identical)} · bytes: {record.wire.bytes}

    -

    - Which is why an editor that speaks LSP and a harness that speaks MCP - both need nothing new. Every verb either of them can reach is on the - command line too, and iyi help lists them. -

    -
    -
    - -
    -
    -

    What it costs

    -

    - On {SPELLED[record.project.length]} modules a pack is no smaller than - the sources it stands in for. The saving is a property of scale, so it - is measured where scale exists: the repository's own{" "} - -line project of {" "} - modules, by a gate that runs both arms. -

    - -

    - Rounds to green were the model's, and they were tied{" "} - {pack.rounds_tied} times and lost {pack.rounds_lost} times. +

    + +
    +
    +
    +

    Does the edit reach anyone?

    +

    Pick an edit and see who has to rebuild.

    +
    +
    + { + diffs.map((d, i) => ( + + )) + } +
    +
    +
    + { + diffs.map((d) => ( +
    +

    {d.label}

    +
    +
    +
    + $ {d.frame.command} +
    +
    {d.output}
    +
    +
    +
    imports {editedModule}
    + {consumers.map((name) => ( +
    + {name} + + {d.moved ? "rebuild" : "up to date"} + +
    + ))} +
    +
    +
    + {d.verdict} + iyi mod diff · interface {d.moved ? "changed" : "unchanged"} +
    +
    + )) + } +
    +
    + +
    +
    +

    How the numbers were measured.

    +

    + On {SPELLED[record.project.length]} modules a pack is no smaller than + the sources it stands in for. So the gate that runs both arms + measures it on {SPELLED[measuredOn.length]} of the repository's + samples whose imports carry bodies:{" "} + {measuredOn.map((target, i) => ( + <> + {i > 0 && (i === measuredOn.length - 1 ? ", and " : ", ")} + {target.file}, against {target.imports} + + ))}. The prompt tokens are from the {taskFile} task, + written once against the pack and once against the sources.

    - -

    - The project is records/agent/ in this site's repository and - the commands are the ones above.{" "} - Install the compiler and the frames on - this page are what you will see. -

    -
    -
    +

    + The project is records/agent/ in this site's + repository. Install the compiler and + the frames on this page are what you will see. +

    +
    +
    +
    + {facts.provenance.generator} + {facts.provenance.commit} +
    +
    +
    samples
    {measuredOn.map((target) => target.file).join(" · ")}
    +
    token task
    {taskFile}
    +
    runs
    {pack.runs}
    +
    arm A
    sources
    +
    arm B
    iyi mod context
    +
    pack size
    {pack.size_low}–{pack.size_high}% of sources
    +
    rounds to green (the model's)
    tied {pack.rounds_tied} · lost {pack.rounds_lost}
    +
    +
    + prompt tokens + −{pack.tokens_low} to −{pack.tokens_high}% +
    +
    + + + + diff --git a/src/pages/index.astro b/src/pages/index.astro index 6159bce..4a9a3e5 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -2,16 +2,16 @@ import { Image } from "astro:assets"; import Page from "../layouts/Page.astro"; import Measure from "../components/Measure.astro"; -import Stamped from "../components/Stamped.astro"; -import DurationChart from "../components/DurationChart.astro"; +import AgentRace from "../components/AgentRace.astro"; +import RebuildRace from "../components/RebuildRace.astro"; import Sample from "../components/Sample.astro"; import Quote from "../components/Quote.astro"; import Platform from "../components/Platform.astro"; import facts from "../generated/facts.json"; import { tourSamples, runnable, playgroundHref } from "../playground/samples"; -import { latestRelease, releaseUrl, releaseAssets, windowsAsset } from "../lib/release"; +import { latestRelease, releaseAssets, windowsAsset } from "../lib/release"; import platforms from "../generated/targets.json"; -import { SPELLED, sequence } from "../lib/lessons"; +import { SPELLED } from "../lib/lessons"; import mascot from "../assets/mascot.png"; const release = latestRelease(); @@ -38,9 +38,6 @@ const loop = facts.recorded.edit_loop; const bin = facts.recorded.hello_binary; const pack = facts.recorded.context_pack; -const ratios = loop.ratios as number[]; -const loopClaim = `About ${loop.claim.value}x less than Crystal, on the line you just changed.`; -const loopBand = `${Math.min(...ratios).toFixed(2)}x to ${Math.max(...ratios).toFixed(2)}x across ${loop.sessions.length} published sessions.`; const massRatio = Math.round(bin.crystal.kb / bin.iyi.kb); /* The platforms README.md says are run every build, out of @@ -60,14 +57,6 @@ if (wasm === undefined) { ); } -/* The lessons, counted rather than stated: this card said "Five lessons" while - * the count lives in src/content/learn, so adding a lesson made the home page - * wrong and no gate could see it. Spelled through the same table and with the - * same fallback the learn index uses, which is what a thirteenth lesson would - * need. */ -const lessons = await sequence(); -const spelled = SPELLED[lessons.length] ?? String(lessons.length); -const lessonCount = `${spelled[0].toUpperCase()}${spelled.slice(1)}`; /* The machines a release ships an archive for, in README.md's own words for * them, through scripts/install-targets.mjs. "Linux x86-64 or macOS arm64" was @@ -78,639 +67,334 @@ const lessonCount = `${spelled[0].toUpperCase()}${spelled.slice(1)}`; const machines = releaseAssets(release).map((asset) => asset.machine); const windows = windowsAsset(release); -const starts = [ - { - title: "Take the tour", - body: "The language in short programs, from hello to traits, generics and tasks. Each one runs in your browser; nothing needs installing.", - href: "playground/", - cta: "Start the tour", - }, - { - title: "Learn the rule", - body: `${lessonCount} lessons on what makes iyi its own language, each one a real program from the repository, and each one ending in a mistake so you meet the compiler's own error on purpose.`, - href: "learn/", - cta: "Start the lessons", - }, - { - title: "Install it", - body: `One command installs the latest release, on ${machines.join(" and ")} and now ${windows.machine} too, with both libraries and the samples. The second command runs hello.`, - href: "install/", - cta: "Install iyi", - }, -]; --- -
    -
    -
    -

    A friendly, fast language for people and their agents.

    -

    - iyi is a compiled language written for two readers: the person - editing the code and the agent editing it beside them. One rule - serves both. A module builds against its dependencies' declarations, - never their bodies, so a build reads less, a program carries less, - and a model reads an interface instead of a repository. -

    - -

    - Version {release.version}, {release.date} -

    -
    -
    - The iyi mascot: a smiling red heart with yellow gloves, waving +
    +
    +
    +

    A friendly, fast language for people and their agents.

    +

    + iyi is a compiled language written for two readers: the person editing + the code and the agent editing it beside them. One rule serves both. A + module builds against its dependencies' declarations, never their + bodies, so: +

    + {/* The three consequences of the rule, as the sentence above lists + them, set so they read at a glance. */} +
      +
    • a build reads less
    • +
    • a program carries less
    • +
    • a model reads an interface instead of a repository
    • +
    + + +
    + {/* The mascot, large, beside the one line. The largest thing painted above + the fold, so it is eager and asks for priority. */} +
    + The iyi mascot: a smiling red heart with yellow gloves, waving +
    -
    +
    + +

    + Module names and the edit are illustrative. The two durations are the + measured ones, from the same compiler binary under each set of rules, + by a generator that refuses to start the clock until both print the + same total. +

    +
    + +
    +
    +

    What iyi is for

    +

    Four things, and they are one design decision seen from four sides.

    +
    + +
    -
    -
    -
    -

    Try iyi

    -

    +

    +
    +

    Try iyi

    +

    It reads like Crystal or Ruby, and builds like a systems language. This is the first program of the tour, and the output beside it is what the compiler's build of it printed.

    -

    - Run it in the playground -

    + Run it in the playground →
    -
    +
    -
    -
    -
    - - {/* The agent argument, before the Crystal one, because it is the reason - this language exists rather than a feature it grew. It is one rule seen - from the other side: a build that reads declarations instead of bodies is - a model that reads declarations instead of bodies, and the card is what - that cost when it was measured. */} -
    -
    -
    -

    Written for agents

    -

    A model reads the interface, not the repository.

    -

    - A module compiles against its dependencies' declarations, never their - bodies. That one rule is why a module's interface is a file, and why{" "} - iyi mod context can hand an agent the exact surface of - every import with no body in it and no repository behind it. The - context window is the new compile time, and this is a language that - spends it like one. -

    -
      +
    + + {/* The agent argument, before the Crystal one, because it is the reason + this language exists rather than a feature it grew. */} +
    +
    +
    +

    Agent experience (AX)

    +

    A model reads the interface, not the repository.

    +

    + A module compiles against its dependencies' declarations, never their + bodies. That one rule is why a module's interface is a file, and why{" "} + iyi mod context can hand an agent the exact surface of + every import with no body in it and no repository behind it. +

    + The loop, recorded → +
    +
    • - Grounding is one command.{" "} - iyi mod context --json prints the surface an edit is - allowed to know. + Grounding is one command. + iyi mod context --json prints the surface an edit is allowed to know.
    • - The verdict is data. iyi check -f json{" "} - answers with the file, the line, the span and the exact replacement - when the compiler knows the fix, and iyi fix applies - it. + The verdict is data. + + iyi check -f json answers with the file, the line, + the span and the exact replacement when the compiler knows the + fix, and iyi fix applies it. +
    • - The servers are in the binary.{" "} - iyi lsp for an editor and iyi mcp for a - harness, both over stdio, both shells around the same front end a - build uses. + The servers are in the binary. + iyi lsp for an editor and iyi mcp for a harness, both over stdio.
    -
    - -

    - Rounds to green were the model's, and they were tied{" "} - {pack.rounds_tied} times and lost {pack.rounds_lost} times. Both - readings come off one gate and both are published. +

    + +

    + A replay of the measured result; the code in the lanes is + illustrative. Rounds to green were the model's, tied{" "} + {pack.rounds_tied} times and lost {pack.rounds_lost}.{" "} + How it was measured.

    - -
    -
    - -
    -
    -
    -

    Coming from Crystal?

    -

    iyi runs your Crystal code today.

    -

    +

    +
    + +
    +
    +

    Coming from Crystal?

    +

    iyi runs your Crystal code today.

    +

    iyi's compiler is Crystal's compiler. Your .cr files build unchanged, and iyi build --crystal gives an iyi program Crystal's standard library, require, and the - shards you already depend on. Nothing to port first, and nothing to - give up: blocks, union types, nil-safety and macros are all here. + shards you already depend on. Blocks, union types, nil-safety and + macros are all here.

    -

    - The listing here is a Kemal server written as an iyi module, and the - console under it is that server answering. What iyi adds is one rule - about where things may be written, and the compiler teaches it as you - go. +

    + The listing is a Kemal server written as an iyi module, and the + console under it is that server answering. What iyi adds is one + rule about where things may be written, and the compiler teaches it + as you go.

    -
    -
    +
    - +
    -
    -
    +
    -
    -
    -
    -

    Install in one line

    -

    +

    +
    +

    Install in one line

    +

    The latest release into ~/.local on{" "} {machines.join(" or ")}, or into %LOCALAPPDATA% on{" "} {windows.machine}, with both libraries and the samples. Then hello.

    -

    What the installer does, and the by-hand version

    + What the installer does, and the by-hand version →
    +
    - - + + -
    -
    - -
    -
    -

    What iyi is for

    -

    - Four things, and they are one design decision seen from four sides. -

    -
      -
    • -

      {loop.claim.value}x

      -

      Quick to rebuild

      -

      - Change one line in one module and build again. iyi reads the - declarations of everything else and none of the bodies, so the edit - loop is {loop.claim.value}x shorter than the same compiler - without the rule. -

      - Why it is fast -
    • -
    • -

      {pack.tokens_low} to {pack.tokens_high}%

      -

      Made for agents

      -

      - Fewer prompt tokens for the same edit, because a module's interface - is a file and not a convention. One command grounds an edit in - every import's exact surface, iyi check answers as - data with suggested edits, and iyi mcp serves it all - over the protocol agents speak. -

      - The loop, recorded -
    • -
    • -

      -

      Portable

      -

      - An iyi program compiles for targets, and is - run on {ranCount} of them on every build. One of those is {wasm}, - which is what the playground on this site uses. -

      - How the playground runs -
    • -
    • -

      {massRatio}x

      -

      Small and quick to start

      -

      - Native code through LLVM, and a program links only what it uses. A - hello world is {massRatio}x smaller than the same program with - Crystal's library. -

      - The numbers, with the machine they ran on -
    • -
    -
    -
    - -
    -
    -

    Measured

    -

    - One line changed in one of modules,{" "} - lines - in all, built by iyi and by the same compiler under Crystal's rules, by - a generator that refuses to start the clock until both print the same - total. The bars run at real speed. -

    - - - -

    - The rest of the numbers, binary size and start time among them, are on - the why page, each one a reading off a - machine rather than a property of the language. -

    -
    -
    - -
    -
    -

    Get started

    -
      - { - starts.map((s) => ( -
    • -

      {s.title}

      -

      {s.body}

      - {s.cta} -
    • - )) - } -
    -
    -
    +
    +
    -
    diff --git a/src/pages/install.astro b/src/pages/install.astro index 7dd7f5c..c36090d 100644 --- a/src/pages/install.astro +++ b/src/pages/install.astro @@ -14,7 +14,9 @@ * scripts/install-targets.mjs. A machine's name is README.md's own word for * it. Nothing here is typed. */ +import { Image } from "astro:assets"; import Page from "../layouts/Page.astro"; +import mascot from "../assets/mascot.png"; import Measure from "../components/Measure.astro"; import Platform from "../components/Platform.astro"; import Quote from "../components/Quote.astro"; @@ -35,282 +37,188 @@ const tarballs = assets.map((asset) => asset.target).join(" or "); title="Install iyi" summary={`Install iyi with one command: the latest release on ${posixLabel} or ${windows.machine}, then run hello. Or unpack the archive by hand, or build from source.`} > -
    -

    Install, version {release.version}

    -

    One command, then hello.

    -

    - Two installers, one per family: a POSIX shell script over{" "} - curl and tar, and a PowerShell one over{" "} - Invoke-WebRequest and .NET's own zip reader. Each picks the - release for your machine, checks it against the checksums the release - publishes before it unpacks anything, and leaves a compiler with nothing - to configure. The second line runs the first sample it shipped with. -

    - - - - -
    - -
    -
    -
    -

    What it does

    -
      +
      +
      +

      + + Install, version {release.version} +

      +

      One command, then hello.

      +

      + Two installers, one per family: a POSIX shell script over curl and tar, + and a PowerShell one. Each picks the release for your machine, checks + it against the checksums the release publishes before it unpacks + anything, and leaves a compiler with nothing to configure. The second + line runs the first sample it shipped with. +

      +
      + +
      + + + + +
      + +
      +
      +

      What it does

      +
      • - Picks the archive for your machine: the tarball - for uname, {tarballs}, or the{" "} - {windows.target} zip on Windows. Anything else is - refused by name, with a pointer to building from source. + Picks the archive for your machine. + + {tarballs}, or the {windows.target} zip. Anything else is refused + by name, with a pointer to building from source. +
      • - Follows the latest release on GitHub, without the - API; today that is {release.version}, from {release.date}.{" "} - IYI_VERSION={release.version} pins one. + Follows the latest release on GitHub. + + Today that is {release.version}, from {release.date}.{" "} + IYI_VERSION={release.version} pins one. +
      • - Checks what it downloaded against the release's - own checksums, and unpacks nothing when they disagree. The{" "} - download page quotes both - installers doing it. + Checks what it downloaded. + + Against the release's own checksums, and unpacks nothing when + they disagree. The download page{" "} + quotes both installers doing it. +
      • - Unpacks into ~/.local, or into{" "} - %LOCALAPPDATA%\Programs\iyi on Windows, where it also - adds bin to your own PATH and needs no - administrator. IYI_PREFIX moves either one; both - archives are relocatable. + Unpacks into ~/.local. + + Or into %LOCALAPPDATA%\Programs\iyi on Windows, where + it also adds bin to your PATH and needs no administrator.{" "} + IYI_PREFIX moves either one. +
      -
      -

      What you get

      -
        +
        +

        What you get

        +
        • - The compiler, and the same binary under Crystal's - name for .cr files. + The compiler. + And the same binary under Crystal's name for .cr files.
        • - Both libraries: iyi's own,{" "} - on disk, and Crystal's - standard library for iyi build --crystal, so your - shards work from the first minute. + Both libraries. + + iyi's own, on disk, and + Crystal's standard library for iyi build --crystal, + so your shards work from the first minute. +
        • - The samples under share/iyi/samples, - the programs the lessons are built on. + The samples. + + Under share/iyi/samples, the programs the{" "} + lessons are built on. +
        • - The tools: iyi check, fix,{" "} - test, doc, mod,{" "} - lsp and mcp, all one binary. + The tools. + iyi check, fix, test, doc, mod, lsp and mcp, all one binary.
        • - Nothing to configure — bin/iyi finds - its libraries beside itself, and LLVM is inside the binary. What - no archive brings is a C toolchain: a program is linked by{" "} - cc, and on Windows by cl.exe, so that - machine needs the Visual C++ build tools. The compiler finds them - and the Windows SDK through the registry, so no developer prompt - and nothing on PATH is needed — and the installer - says so when the toolset is missing. + Nothing to configure. + + LLVM is inside the binary. What no archive brings is a C + toolchain: a program is linked by cc, and on Windows by cl.exe, + so that machine needs the Visual C++ build tools. The compiler + finds them and the Windows SDK through the registry, so no + developer prompt and nothing on PATH is needed, and the + installer says so when the toolset is missing. +
        -
      -
      - -
      -

      By hand

      -

      - The installer does two things you can do yourself. Download the archive - for your machine, from the release this page is about, and unpack it: -

      - -
      -
        - {assets.map((asset) => ( -
      • - {asset.machine} - {asset.url} -
      • - ))} -
      - +
      + +
      +
      +

      By hand

      +

      + The installer does two things you can do yourself. Download the + archive for your machine and unpack it. +

      -
      - - +
      +
      +
      + {assets.map((asset) => {asset.machine} ↓)} +
      + +
      +
      - -

      - Older versions, and the notes for each, are under{" "} - releases. The - checksums are published beside them, which is what the installers read. -

      -
      - -
      -

      From source

      -

      - Other platforms, or the current tree rather than a release: clone the - repository and make. It needs a Crystal compiler to - bootstrap from and LLVM; the README's{" "} - Getting it{" "} - section names the versions and every command. -

      -
      - -
      -

      Then

      - -
      +

      + Older versions, and the notes for each, are under{" "} + releases. The + checksums are published beside them. +

      +
    + +
    +
    +

    From source

    +

    + Other platforms, or the current tree rather than a release: clone the + repository and make. It needs a Crystal compiler to + bootstrap from and LLVM; the README's{" "} + Getting it{" "} + section names the versions and every command. +

    + github.com/iyilang/iyi → +
    + +
    + diff --git a/src/pages/learn/[...slug].astro b/src/pages/learn/[...slug].astro index d4cf899..7116bb5 100644 --- a/src/pages/learn/[...slug].astro +++ b/src/pages/learn/[...slug].astro @@ -46,7 +46,6 @@ import { breakCases, inlineMarkdown, lessonHref, - SPELLED, type Lesson, } from "../../lib/lessons"; @@ -74,35 +73,27 @@ interface Props { const { lesson, previous, next, total } = Astro.props; const { Content, headings } = await render(lesson); +/* The step's own sections, read off the rendered entry, so a contents entry + * and the heading it names cannot disagree. */ +const sections = headings.filter((heading) => heading.depth === 2 || heading.depth === 3); + const base = import.meta.env.BASE_URL; const rules = rulesOf(lesson); const run = runOf(lesson); const sources = lesson.data.sources ?? []; const indexHref = `${base.replace(/\/$/, "")}/learn/`; -const spelled = SPELLED[total] ?? String(total); - -// The step's position in the path, drawn as ticks rather than written twice. -// Structural: it is the `order` in the file, so it is the same on every -// machine, and it is not a claim about what the reader has read. -const ticks = Array.from({ length: total }, (_, i) => i + 1); - -// The lesson's own sections, read off the rendered entry rather than repeated -// in frontmatter. The ids are the ones Astro's markdown pipeline slugged and -// the ones `_Heading.astro` links to, so a contents entry and the heading it -// names cannot disagree. `##` and `###` only: `#` is the lesson's title, which -// the page already sets, and anything deeper is not a place a reader navigates -// to. No count of them and no estimate of anything: doc/ART-DIRECTION.md -// forbids a time estimate in copy, and a list of sections is a map, not a -// promise about how long the walk takes. -const sections = headings.filter( - (heading) => heading.depth === 2 || heading.depth === 3, -); // Every recorded rejection this step shows. The first is the one the lesson's // closing paragraph leads into and the one that carries the extra files the // rejection needs to make sense; the rest are further cases recorded under the // same rule, shown after it rather than left in the record unrendered. const broken = breakCases(lesson); + +/* Every step, with the rules it teaches, for the path across the top. */ +const steps = (await sequence()).map((step) => ({ + lesson: step, + rules: rulesOf(step).map((rule) => rule.id).join(" "), +})); --- -
    -
    -

    - Learn, step {lesson.data.order} of {total} -

    - -

    {lesson.data.title}

    -

    -

    - - { - /* In this step, in the rail. The sections are the lesson's own `##` and - * `###`, so this is a map of the page rather than a second outline - * somebody has to keep in step with it, and every entry links to the id - * the heading carries. Beside the opening rather than floating: a reader - * decides here whether to read the step or jump to the part they came - * for. */ - sections.length > 0 && ( - - ) - } - -
    -

    What the rule says

    - { - rules.map((rule) => ( -
    -

    - - {rule.id} {rule.cite} - -

    - )) - } -
    - -
    -

    Run it

    -

    - This step is grounded in {run.path},{" "} - {run.lines} lines in the repository, read out of the tree when this - page is built. -

    -

    + + +

    +

    + Learn · step {lesson.data.order} of {total} +

    +

    {lesson.data.title}

    +

    { - run.href ? ( - - Run it in your browser - - {" "}from the module the compiler built for it. - - - ) : ( - - This program does not run in the browser. Locally it is{" "} - iyi run {run.path}. - + sections.length > 0 && ( +

    ) } -

    - {run.note &&

    {run.note}

    } -
    - -
    - -
    - {/* The headings carry a link to themselves, which is the one thing the - * markdown pipeline does not render: it slugs every heading and nothing - * emits an anchor for the slug. Mapped rather than written into each - * lesson, so a new lesson gets it by existing. */} - -
    - - { - broken.map((entry, i) => ( - - )) - } + -
    -
    -

    What the rule bought

    -

    -

    - - { - /* Provenance at the foot rather than in the rail beside the opening. The - * listings and the quoted recordings already print their own file and - * lines where they sit, so this is the shorter list of what the prose - * leans on without showing, and a lesson's opening is not improved by a - * column of citations running past it. */ - sources.length > 0 && ( -
    -
    -

    Standing on

    -
    - {sources.map((source) => ( -
    -
    {source.file}
    -
    -
    - ))} -
    -
    - ) - } +
    +

    What the rule says

    + { + rules.map((rule) => ( +
    + {rule.id}{" "} + + + {rule.id} {rule.cite} + +
    + )) + } - -
    + + + +
    diff --git a/src/pages/learn/index.astro b/src/pages/learn/index.astro index 445d678..7e50a51 100644 --- a/src/pages/learn/index.astro +++ b/src/pages/learn/index.astro @@ -21,7 +21,10 @@ * against the wasm recording there, so a step this page offers is a step that * has a page and a recorded run. */ +import { Image } from "astro:assets"; import Measure from "../../components/Measure.astro"; +import TourGrid from "../../components/TourGrid.astro"; +import mascot from "../../assets/mascot.png"; import Page from "../../layouts/Page.astro"; import { sequence, @@ -69,6 +72,25 @@ const tour = tourSections.map((section) => ({ })); const tourStart = tour[0]?.steps[0]; + +// A rule's premise as SPEC.md states it, cut to its first three sentences +// where it runs long: the step's own page carries the whole of it, and the +// index links there rather than reprinting a paragraph. +const LONG = 400; +function excerpt(premise: string): { text: string; cut: boolean } { + if (premise.length <= LONG) return { text: premise, cut: false }; + let at = -1; + for (let i = 0; i < 3; i++) { + const next = premise.indexOf(". ", at + 1); + if (next === -1) return { text: premise, cut: false }; + at = next; + } + return { text: premise.slice(0, at + 1), cut: true }; +} + +// The steps that ran natively rather than in the browser, named under the +// grid rather than typed into the note. +const nativeIds = tourSamples.filter((step) => !runnable(step)).map((step) => step.id); if (tourStart === undefined) { throw new Error( "samples/tour/tour.json laid out no sections, so this page would offer a " + @@ -132,163 +154,143 @@ const further = [ title="Learn iyi" summary={`${count} steps in order, one rule each. Compile against declarations rather than bodies, write down what you export, own your own names, replace open classes with traits, type a body where it is written, cross a boundary with a generic, keep a macro to one declaration, and return errors as ordinary values.`} > -
    -
    -

    Learn

    -

    {count} steps, one rule each

    -

    +

    +
    +

    + + Learn +

    +

    {count} steps, one rule each

    +

    iyi is Crystal with one rule added, and that rule changes enough that starting from the syntax would teach you the wrong thing. So these steps start from the rule, in the order the rules depend on each other.

    -

    +

    Each step opens with the rule in the specification's own words, shows the program it is grounded in, breaks the rule on purpose, and prints what the compiler said. Every listing is read out of the repository when the page is built, and every error is a recording of a real run.

    -

    - Start at step one, {first.lesson.data.title} -

    -

    - Or start with the syntax. The tour is{" "} - {tourSamples.length} short programs that run in your browser, from one{" "} - puts to tasks and channels. -

    -
    - -
    -
    the path
    - { - steps.map(({ lesson, rules }) => ( -
    - {lesson.data.order} - {rules.map((rule) => rule.id).join(" ")} -
    - )) - } -
    - Each identifier is the specification's own, and the sentence it states - for it is beside the step. -
    -
    -
    - -
      - { - steps.map(({ lesson, href, rules, broken, next }) => ( -
    1. -
      - -
      -

      - Step {lesson.data.order} of {steps.length} - {lesson.data.order === 1 && start here} -

      - -

      - {lesson.data.title} -

      -

      -

      - { - next ? ( - <> - Then step {next.data.order}, {next.data.title}. - - ) : ( - <>The last step. The syntax tour follows below. - ) - } -

      -
      - -
      -
      teaches
      - {rules.map((rule) => ( -
      - {rule.id} - -
      - ))} -
      grounded in
      -
      {lesson.data.sample}
      -
      breaks
      - {broken.map((entry) => ( -
      {entry.title}
      - ))} -
      -
    2. - )) - } -
    - -
    -
    -
    -

    The second track

    -

    The syntax, in {tourSamples.length} short programs

    -

    - The rules are what make iyi a different language from the one it - forked. The tour is everything else: the syntax, in the order a person - learns a language, each step a program that runs in your browser from the - module the compiler built for it. Read the rules first and the tour - reads as confirmation. Read the tour first and the rules read as the - reason. -

    + +
    -
      +
      +
      +
      +

      The path

      +

      + Each identifier is the specification's own, and the sentence it + states for it is beside the step. +

      +
      + +
      + +
        { - tour.map((section) => ( -
      1. -

        {section.title}

        -

        {section.blurb}

        -

        - {section.steps.map((step) => ( - - {step.id} - + steps.map(({ lesson, href, rules, broken }) => ( +

      2. +
        + {lesson.data.order} + {rules.map((rule) => ( + {rule.id} ))} -

        +
        +
        +

        + {lesson.data.title} +

        +

        + {rules.map((rule) => { + const shown = excerpt(rule.premise); + return ( +

        + + {shown.cut && ( + <> + {" "} + The full rule → + + )} +

        + ); + })} +
        + + grounded in + {lesson.data.sample} + + + breaks + {broken.map((entry) => entry.title).join(" · ")} + +
        +
      3. )) }
      +
      -

      - A step set in the quieter face ran natively rather than in the browser: - tasks and channels have no wasm target yet, so those two pages show - what the program printed on the machine the recording names. -

      +
      +
      +

      The second track

      +

      The syntax, in {tourSamples.length} short programs

      +

      + The rules are what make iyi a different language from the one it + forked. The tour is everything else, in the order a person learns a + language, each program running in your browser. Read the rules first + and the tour reads as confirmation. Read the tour first and the rules + read as the reason. +

      +
      + +
      + { + nativeIds.length > 0 ? ( + + native {nativeIds.join(" and ")}{" "} + {nativeIds.length === 1 ? "has" : "have"} no wasm target yet, so{" "} + {nativeIds.length === 1 ? "it shows" : "those show"} what the + program printed natively. + + ) : ( + + ) + } + Start the tour at {tourStart.id} → +
      -
      -
      -

      And the rest of the samples

      -

      - Beyond both tracks, samples/iyi holds{" "} - programs, each written to document a part of - the design rather than to show off. The samples no step is grounded - in are the ones to read next, and each of them runs here too: -

      -
        +
        +
        +

        And the rest of the samples

        +

        + Beyond both tracks, samples/iyi holds{" "} + programs, each written to document a part + of the design. The ones no step is grounded in are the ones to read + next: +

        +
        +
          { further.map((sample) => (
        • {sample.name} - {sample.blurb} + {sample.blurb}
        • )) } @@ -298,303 +300,145 @@ const further = [ diff --git a/src/pages/llms.txt.ts b/src/pages/llms.txt.ts index 4ab0584..8e825af 100644 --- a/src/pages/llms.txt.ts +++ b/src/pages/llms.txt.ts @@ -91,7 +91,7 @@ for Type\` lives with the trait or the type (R-3). modules and rebuild in ${loop.best.iyi} s, against ${loop.best.crystal} s under Crystal's rules on the same compiler binary: ${loop.claim.value}x less. Measured by \`${loop.command}\` on ${loop.machine}. -- AI experience: a module's interface is a file (.iyimod). \`iyi mod context\` +- Agent experience (AX): a module's interface is a file (.iyimod). \`iyi mod context\` grounds an edit at ${pack.size_low} to ${pack.size_high}% of the sources' size; a model writing against it spent ${pack.tokens_low} to ${pack.tokens_high}% fewer prompt tokens over ${pack.runs} measured runs (\`${pack.command}\`). The loop is one verb per step, and every step of diff --git a/src/pages/playground/[...sample].astro b/src/pages/playground/[...sample].astro index 4566ee0..198fc20 100644 --- a/src/pages/playground/[...sample].astro +++ b/src/pages/playground/[...sample].astro @@ -67,7 +67,6 @@ interface Props { } const { sample } = Astro.props as Props; -const base = import.meta.env.BASE_URL.replace(/\/*$/, "/"); const runs = "wasm" in sample; /* The slice for this sample. Missing is fatal rather than silently absent: a @@ -95,24 +94,4 @@ if (entry === undefined) { > -
          -

          - { - runs - ? "This page runs the module the compiler built from the listing above. You can type in the pane: nothing here compiles, so what you type cannot run, and the page says which bytes it is running the moment the text stops being the recorded file. There is no standard input, no filesystem and no threads on this target, and one sample fails on purpose for exactly that reason." - : "This program does not compile for the browser's target, so this page shows what it printed when the compiler ran it natively, on the machine named above. You can still type in the pane, and the page will say when what is in it is no longer the file the recording is about." - }{" "} - The recordings behind this{" "} - say which bytes ran and what checked them. -

          -
          - - diff --git a/src/pages/playground/index.astro b/src/pages/playground/index.astro index 55c7c75..13e05fb 100644 --- a/src/pages/playground/index.astro +++ b/src/pages/playground/index.astro @@ -16,9 +16,11 @@ * `src/playground/progress.ts`, which reads local storage, and marks the list * afterwards. One state, and it claims nothing more than it knows: opened. */ +import { Image } from "astro:assets"; import Page from "../../layouts/Page.astro"; -import { runnable, tourSamples, tourSections } from "../../playground/samples"; -import { recordedText } from "../../lib/highlight"; +import TourGrid from "../../components/TourGrid.astro"; +import mascot from "../../assets/mascot.png"; +import { tourSamples } from "../../playground/samples"; import { SPELLED, lessonHref, sequence } from "../../lib/lessons"; const base = import.meta.env.BASE_URL.replace(/\/*$/, "/"); @@ -26,23 +28,6 @@ const lessons = await sequence(); const firstLesson = lessons[0]; const first = tourSamples[0]; -/* Each program's first line is a comment saying what it is; the list shows - * that line, so the description cannot drift from the file. */ -const blurbOf = (path: string) => - recordedText(path).split("\n")[0].replace(/^#\s*/, ""); - -let n = 0; -const sections = tourSections.map((section) => ({ - title: section.title, - blurb: section.blurb, - steps: section.steps.map((step) => ({ - n: ++n, - id: step.id, - href: `${base}playground/${step.id}/`, - blurb: blurbOf(step.path), - runs: runnable(step), - })), -})); --- ({ title="A tour of iyi, in the playground" summary={`A tour of iyi in ${tourSamples.length} short programs, each of which runs in your browser. Read one, press Run, see what it prints.`} > -
          -

          Playground

          -

          A tour of iyi, in {tourSamples.length} short programs.

          -

          - Start at the top and work down. Read the program, press Run, see what it - prints, then move to the next. Each one is a page you can come back to. -

          - Start with {first.id} - - {/* Empty in the HTML on purpose. Whatever it says is a fact about this - browser's own storage, so this browser says it or nobody does. */} - - -
          - -
          - { - sections.map((section) => ( -
          -

          {section.title}

          -

          {section.blurb}

          -
            - {section.steps.map((step) => ( -
          1. - - {step.n} - {step.id} - {step.blurb} - {!step.runs && recorded} - {/* Filled in by the island, and empty until then. */} - - -
          2. - ))} -
          -
          - )) - } -
          - -
          -

          - Done with the tour? Learn the - one rule that makes iyi its own language, in {SPELLED[lessons.length]} - {" "}lessons that each run a real program from the repository. -

          -

          - Each program runs from the module the iyi compiler built for it, checked - against its recording before it starts. A step marked recorded uses tasks, - which the browser's target cannot run yet, so it shows what the program - printed natively instead. You can type in any of the panes, and the page - says which bytes a run will use the moment what is in one stops being the - recorded file, because nothing on these pages compiles what you type.{" "} - How this works -

          -
          +
          +
          +

          + + Playground +

          +

          A tour of iyi, in {tourSamples.length} short programs.

          +

          + Start at the top and work down. Read the program, press Run, see what + it prints, then move to the next. Each one is a page you can come back + to. +

          +
          + Start with {first.id} → + {/* Empty in the HTML on purpose. Whatever it says is a fact about + this browser's own storage, so this browser says it or nobody + does. */} + +
          + +
          + +
          + +
          +

          + Done with the tour? Learn the + one rule that makes iyi its own language, in {SPELLED[lessons.length]} + {" "}lessons that each run a real program from the repository. +

          +

          + native Each program runs from the module the iyi + compiler built for it, checked against its recording before it + starts. A step marked native uses tasks, which the browser's target + cannot run yet, so it shows what the program printed natively + instead. You can type in any of the panes, and the page says which + bytes a run will use the moment what is in one stops being the + recorded file, because nothing on these pages compiles what you + type.{" "} + How this works +

          +
          +
          +
          diff --git a/src/pages/project/[doc].astro b/src/pages/project/[doc].astro index 18ba085..b8490de 100644 --- a/src/pages/project/[doc].astro +++ b/src/pages/project/[doc].astro @@ -63,127 +63,160 @@ const others = project.documents.filter((entry) => entry.id !== doc.id); title={doc.title} summary={`${doc.blurb} Published verbatim from ${doc.file} in the iyi repository, ${doc.lines} lines, reassembled byte for byte at build time.`} > -
          -

          - Project, verbatim -

          -

          {doc.title}

          -

          {doc.blurb}

          -

          - Published whole, out of {doc.cite}. -

          -
          - -
          -
          - {body.map((block) => { - if (block.kind === "heading") { - /* A comparison with `<` inside the template is read as the start of - * a tag by Astro's parser, so the level picks its element here and - * the element is rendered by name. The document's own h1 is the page - * heading, so everything below it moves down one. */ - const Heading = block.level === 3 ? "h3" : block.level === 4 ? "h4" : "h2"; - return ; - } - if (block.kind === "paragraph") return

          ; - if (block.kind === "list") { - return block.ordered ? ( -

            {(block.items ?? []).map((item) =>
          1. )}
          - ) : ( -
            {(block.items ?? []).map((item) =>
          • )}
          - ); - } - if (block.kind === "code") { - return ( - - ); - } - if (block.kind === "rule") return
          ; - /* A link definition block and an HTML comment render as nothing: they - * are the document's plumbing, already resolved into the links above, - * and printing them would be printing markdown at a reader. */ - return null; - })} +
          +
          +
          +

          + Project, verbatim +

          +

          {doc.title}

          +

          {doc.blurb}

          +
          + +
          +

          Published whole, out of {doc.cite}.

          + {body.map((block) => { + if (block.kind === "heading") { + /* A comparison with `<` inside the template is read as the start + * of a tag by Astro's parser, so the level picks its element here + * and the element is rendered by name. The document's own h1 is + * the page heading, so everything below it moves down one. */ + const Heading = block.level === 3 ? "h3" : block.level === 4 ? "h4" : "h2"; + return ; + } + if (block.kind === "paragraph") return

          ; + if (block.kind === "list") { + return block.ordered ? ( +

            {(block.items ?? []).map((item) =>
          1. )}
          + ) : ( +
            {(block.items ?? []).map((item) =>
          • )}
          + ); + } + if (block.kind === "code") { + return ( + + ); + } + if (block.kind === "rule") return
          ; + /* A link definition block and an HTML comment render as nothing: + * they are the document's plumbing, already resolved into the links + * above. */ + return null; + })} + +

          The rest

          +
            + {others.map((entry) => ( +
          • + {entry.title} + {entry.blurb} +
          • + ))} +
          +
          -
          - -
          -

          The rest

          -
            - {others.map((entry) => ( -
          • - {entry.title} - {entry.blurb} -
          • - ))} -
          -
          +
    diff --git a/src/pages/why.astro b/src/pages/why.astro index 08a0c5b..e98bb66 100644 --- a/src/pages/why.astro +++ b/src/pages/why.astro @@ -1,452 +1,352 @@ --- +import { Image } from "astro:assets"; import Page from "../layouts/Page.astro"; import Measure from "../components/Measure.astro"; import Stamped from "../components/Stamped.astro"; import DurationChart from "../components/DurationChart.astro"; import Quote from "../components/Quote.astro"; -import Compare from "../components/Compare.astro"; import facts from "../generated/facts.json"; import platforms from "../generated/targets.json"; import proofs from "../generated/proofs.json"; import { SPELLED } from "../lib/lessons"; +import mascot from "../assets/mascot.png"; const base = import.meta.env.BASE_URL; const loop = facts.recorded.edit_loop; const bin = facts.recorded.hello_binary; -const lib = facts.recorded.library_size; const front = facts.recorded.front_end; const pack = facts.recorded.context_pack; const runtime = facts.recorded.runtime; +const lib = facts.recorded.library_size; const ratios = loop.ratios as number[]; -const loopClaim = `About ${loop.claim.value}x less than Crystal, on the line you just changed.`; -const loopBand = `${Math.min(...ratios).toFixed(2)}x to ${Math.max(...ratios).toFixed(2)}x across ${loop.sessions.length} published sessions.`; +const loopBand = `${Math.min(...ratios).toFixed(2)}x to ${Math.max(...ratios).toFixed(2)}x across ${loop.sessions.length} published sessions. The seconds moved; the ratio held, which is why the ratio is the claim.`; +/* The hello binary at true area: Crystal's square is the plot, and iyi's is + * the square whose area is the measured fraction of it. Drawn by width it + * would flatter the smaller number. */ const massRatio = bin.crystal.kb / bin.iyi.kb; -const crystalSide = 216; -const iyiSide = crystalSide / Math.sqrt(massRatio); +const plotSide = 158; +const iyiSide = plotSide / Math.sqrt(massRatio); const startRatio = (bin.crystal.ms / bin.iyi.ms).toFixed(1); -const stringRow = runtime.rows.find((r) => r.workload === "string building")!; - -const packClaim = `${pack.tokens_low} to ${pack.tokens_high}% fewer prompt tokens, for no fewer rounds.`; -const packBand = `The grounding pack is ${pack.size_low} to ${pack.size_high}% of the size of the sources it replaces, over ${pack.runs} measured runs.`; +const stringRow = runtime.rows.find((r) => r.workload === "string building"); +if (!stringRow) { + throw new Error("why: bench/runtime.py recorded no string building row, so there is no loss to name"); +} /* The platforms, out of scripts/targets.mjs: the list the workflow - * type-checks and the subset README.md says is run every build. Typed here - * once and it went stale once, which is why it is read now. */ -const targets = platforms.typechecked; + * type-checks and the subset README.md says is run every build. The ones that + * run are listed first. */ const ran = new Set(platforms.ran); -const ranCount = SPELLED[platforms.ran.length]; +const targets = [ + ...platforms.typechecked.filter((t) => ran.has(t)), + ...platforms.typechecked.filter((t) => !ran.has(t)), +]; +const ranCount = SPELLED[platforms.ran.length] ?? String(platforms.ran.length); const ranHow = platforms.ran.map((t) => platforms.how[t as keyof typeof platforms.how]); -/* The object audit, which is not universal: scripts/targets.mjs reads how many - * triples README.md says CI reads the emitted object for and which ones it - * says are type-checked only, and checks the arithmetic against the workflow's - * list. The sentence below claimed the audit covered every target that is not - * run, which was two triples too many, and no gate could see it: a triple - * carries no time or size unit. */ +/* The object audit and the Windows jobs, as the live page states them: the + * audit count and its exceptions read out of README.md by + * scripts/targets.mjs, the Windows jobs counted out of the workflow by + * scripts/proofs.mjs, so neither is typed here. */ const auditedCount = SPELLED[platforms.audited.length] ?? String(platforms.audited.length); const exempt = platforms.auditExempt; - -/* The jobs that execute on a Windows machine, out of scripts/proofs.mjs: a - * job's `runs-on` label is where its steps ran, which is the difference - * between a target that compiles and a target that was exercised. This - * paragraph said Windows compiled and linked and that what it printed could - * not be trusted; 0.14.0 is where that stopped being true, and it stops being - * true here by counting the jobs rather than by an editor noticing. */ const onWindows = proofs.native.filter((job) => job.os === "windows"); -/* And which of them a tag waits for: "a Windows failure stops a release" is - * counted out of the workflow's own `needs` list, not asserted here. */ const gatedOnWindows = onWindows.filter((job) => proofs.gate.needs.includes(job.id)); /* The modules a rebuild did not read: the bench project's module count, less - * the one whose line changed. The word was written into the sentence below as - * "twenty-nine" while the home page wrote "30" into its own, about the same - * project - two hand-typed statements of one figure, already disagreeing. The - * count is counted now (bench/site_facts.py runs the bench's own generator and - * refuses a count README.md contradicts) and the word is indexed, so the - * sentence follows the tree instead of remembering it. */ + * the one whose line changed, spelled through the same table as the rest of + * the site so the sentence follows the tree. */ const rest = facts.structural.modules - 1; -/* The digits if the bench ever outgrows the spelling table: a sentence reading - * "the other 49 modules" is plain, and one reading "the other undefined - * modules" is what indexing past the end would have printed. */ const untouched = SPELLED[rest] ?? String(rest); + +/* Four of the moves, said shortly. The whole table is README.md's, and the + * page says so under the list. */ +const moves = [ + { from: 'require "foo"', to: "import app/foo", why: "A module has a name, and its path is its file's path.", rule: "R-1" }, + { from: "a required file's names are just there", to: "import app/foo::{bar}", why: "The consumer writes what comes in. A library cannot take a name in your file.", rule: "R-2b" }, + { from: "reopen any class, even String", to: "you may not reopen", why: "What a type is, is settled where it is written.", rule: "R-3" }, + { from: "public unless private", to: "private unless pub, and pub writes its types", why: "Exports carry full types; everything inside still infers.", rule: "R-2" }, +]; + +const shards = ["kemal", "db", "ameba", "sqlite3"]; --- -
    -
    -

    Why iyi

    -

    Your Crystal code, rebuilt {loop.claim.value}x faster, with a compiler your agents can talk to.

    -

    - iyi is one design decision seen from four sides. A module compiles - against its dependencies' declarations, never their - bodies. So a build reads less, a program carries less, a tool can read - an interface without a repository, and a person waits less. Every - number on this page was measured by a gate in the repository, and the - ticked cards are the ones that came off a machine rather than out of - the tree. +

    +
    +

    + + Why iyi +

    +

    + Your Crystal code, rebuilt {loop.claim.value}x faster, with a compiler your agents can talk to. +

    +

    + One design decision seen from four sides: a module compiles against + its dependencies' declarations, never their bodies. Every number on + this page was measured by a gate in the repository.

    -
    + + + +
    +
    +

    First

    +

    It runs your Crystal code. Today.

    +

    + iyi's compiler is Crystal's compiler. Your .cr files build unchanged, + and iyi build --crystal brings Crystal's standard + library, require, and your shards. There is no port to + do first. +

    -
    -
    - -
    -
    -

    Two things first

    -
    -
    -

    It runs your Crystal code. Today.

    -

    - iyi's compiler is Crystal's compiler. Your .cr files - build unchanged, iyi build --crystal gives an iyi - program Crystal's standard library and require, and the - shards you already use come with it. Kemal, db, ameba, sqlite3: they - build and they run. There is no port to do first. -

    -

    What moved, in one table

    -
    -
    -

    It compiles faster. Measured.

    -

    - Change one line in a {facts.structural.generated.toLocaleString("en-GB")}-line - project and rebuild. iyi reads the declarations of the - other {untouched} modules and none of their bodies, so the line you just - changed costs {loop.claim.value}x less than under Crystal's - rules, on the same compiler binary. The bars below run at real speed. -

    -

    The measurement, with its machine

    -
    +
      + { + shards.map((shard) => ( +
    • + {shard} + builds and runs +
    • + )) + } +
    +
    + +
    +
    +

    Coming from Crystal

    +

    What moved, and why.

    +

    + The syntax is Crystal's. What moved is where things may be written. + Macros are kept, and they travel in the artifact; nil, union types, + blocks and local inference are kept, unchanged. +

    -
    - - -
    -
    -

    Coming from Crystal

    -

    What moved, and why.

    -

    - The syntax is Crystal's. What moved is where things may be written, - and each move is one of the four rules. A .cr file still - compiles unchanged; the rows below are about .iyi files. -

    - -
    -
    - -
    -
    -

    The four

    -

    One rule, four sides.

    -
      -
    1. 1 Developer experience
    2. -
    3. 2 AI experience
    4. -
    5. 3 Portability
    6. -
    7. 4 Performance and efficiency
    8. +
        + { + moves.map((move) => ( +
      1. + {move.from} + + {move.to} + {move.why} + {move.rule} +
      2. + )) + }
      -
    -
    - - {/* 1. DX ------------------------------------------------------------- */} -
    -
    -

    1. Developer experience

    -

    The loop you are actually in is the loop that got fast.

    -

    - Day-to-day work happens in the edit loop: change a line and rebuild. - That loop is what R-1 was written for, and it is the number this - project exists for. +

    + Four of the moves, condensed from README.md. Traits are step 4 of{" "} + Learn; shards and{" "} + iyi migrate are in the README.

    - - - +
    + +
    +
    +

    1 · Developer experience

    +

    The loop you are actually in is the loop that got fast.

    +

    + Change one line in a -line project and + rebuild. iyi reads the declarations of the other {untouched} modules + and none of their bodies, on the same compiler binary. +

    +
    + + + - -
      -
    • - Errors name the rule, and the fix. A refusal says - which rule it enforces and writes out the line you meant. The - compiler teaches the language at the moment you break it. -
    • -
    • - A framework's promise is checked at your line. Below, - a Kemal port refuses a handler that returns something a body cannot - be made of, by a module that has never heard of your types. Kemal - itself accepts the same block and serves an empty body forever. -
    • -
    • - The tools are the compiler. iyi lsp{" "} - answers completion, references, rename, hover and diagnostics from - the same front end a build uses, so a build, iyi check - and your editor cannot disagree about what is clean.{" "} - iyi tool format knows the syntax. iyi mod dump{" "} - prints a compiled module as text. -
    • -
    - - - -
    - - {/* 2. AIX ----------------------------------------------------------- */} -
    -
    -

    2. AI experience

    -

    A compiler built to be an agent's tool, not wrapped as one.

    -

    - An agent needs a boundary it can read and a loop it can afford. - Without those, it reads whole repositories into a prompt and hopes. - iyi gives it both, for the same reason a person gets them: a module's - interface is a file, not a convention. -

    - -
    -
    -

    The interface is a file

    -

    - A .iyimod holds a module's declarations, with every - type written down, and its machine code. A tool can read exactly - what a module offers without the repository that produced it, and - check a change against it without building the world. It is R-1 - and R-2, asked a question. -

    -

    "Did my change reach anybody?" has an answer

    -

    - iyi mod diff compares two artifacts and says whether - the interface moved, which consumers have to be rebuilt, - and which declarations are gone or new. Rename a local and it says - the interface is unchanged. --exit-code makes that a - branch in a script, or in an agent's plan. +

    +
    + Errors name the rule, and the fix. +

    A refusal says which rule it enforces and writes out the line you meant.

    +
    +
    + A framework's promise is checked at your line. +

    + A Kemal port refuses a handler whose return a body cannot be made + of. Kemal accepts the same block and serves an empty body forever.

    - + The tools are the compiler. +

    + iyi lsp answers from the front end a build uses, so + the build, iyi check and your editor cannot disagree. +

    - -

    The loop, one verb per step

    -
      -
    1. - iyi mod context FILE - - Ground. Prints what an edit to this file needs to - know: every import's exact surface, nothing else. Cut it further - with --budget N, by a stated ladder rather than by - truncation. - -
    2. -
    3. - iyi check -f json - - Check. The front end's verdict as data: file, - line, column, the rule, and a suggested_edit span the - compiler already computed. No binary is built. The exit code is the - answer. - -
    4. -
    5. - iyi fix - - Fix. Applies exactly those edits, one per round, - recompiling between, until clean or until an error carries no - edit. Nothing is invented. - -
    6. -
    7. - iyi test --affected FILE - - Test. Runs only the tests whose import closure - reaches the change, decided in milliseconds and exactly. - -
    8. -
    9. - iyi run --sandbox - - Run, safely. Cross-compiles to wasm32-wasi and - runs under a host that preopens nothing: no filesystem, no - network, no environment. Generated code executes against a - runtime holding nothing. - -
    10. -
    11. - iyi mcp · iyi lsp - - Serve. The same verbs over the Model Context - Protocol for agents and the Language Server Protocol for editors. - Each is a shell around the same binary, so what a harness gets over - the wire is byte for byte what a shell would have printed. No agent - mode, no forked behavior. - -
    12. -
    - - -

    - A model writing against the grounding pack instead of the sources - spent {pack.tokens_low} to {pack.tokens_high}% fewer prompt tokens. - The rounds to get to green are the model's, and they are not iyi's - win: tied {pack.rounds_tied} times and lost {pack.rounds_lost}, and - with iyi fix in the loop the last run tied. Counted, not - timed, so there is no machine to name. The whole loop is gated in CI - by bench/agent_loop.py, ground to check to fix to - affected tests to MCP to sandbox, beside an LSP session. + +

    + +
    +
    +

    + {/* The address this section had before the redesign, kept so a + link to it still lands here. Inline and empty, so it takes no + cell of the grid. */} + 2 · Agent experience (AX)

    - - -

    - The mechanism is built and the claim is young. If the rules turn out - not to be enough for agents, that is a thing to measure rather than - to promise. -

    -
    -
    - - {/* 3. Portability --------------------------------------------------- */} -
    -
    -

    3. Portability

    -

    Compiles for . Runs on {ranCount}, every build.

    -

    - The object is cross-compiled here and linked there with the target's - own toolchain, which is the command --cross-compile{" "} - prints. CI type-checks the library for all {" "} - every push, and {ranCount} of them are run: the check is that they - print what the same program printed on the machine that compiled them. -

    +

    A compiler built to be an agent's tool, not wrapped as one.

    +

    + A .iyimod holds a module's declarations, with every type + written down, and its machine code. A tool can read exactly what a module offers without + the repository, and check a change against it without building the + world. +

    +

    + iyi mod diff answers "did my change reach anybody?" + Rename a local and the interface is unchanged.{" "} + --exit-code makes that a branch in a script, or in an + agent's plan. +

    +

    + The mechanism is built and the claim is young. If the rules turn out + not to be enough for agents, that is a thing to measure rather than + to promise. +

    + See the agent loop, recorded → +
    + +
    + +
    +
    +

    3 · Portability

    +

    Compiles for . Runs on {ranCount}, every build.

    +

    + CI type-checks the library for all on every + push, and runs {ranCount}: the check is that they print what the + same program printed where it was compiled. The wasm32 build is one + self-contained module, which is why the{" "} + tour runs in your browser. +

    +

    + The {ranCount} that run are {ranHow.join(", ")}. Of the{" "} + , {auditedCount} have the object they emit + audited for what it asks the machine for;{" "} + { + exempt.map((t, i) => ( + <>{i === 0 ? "" : i === exempt.length - 1 ? " and " : ", "}{t} + )) + }{" "} + are not. Windows is not in the {ranCount}, and is no longer only + compiled for either: {onWindows.length} jobs execute on a Windows + machine every build, and{" "} + {gatedOnWindows.length === onWindows.length ? "every one" : `${gatedOnWindows.length} of them`}{" "} + is on the list a tag waits for, so a failure there stops a release. + The platforms page names them with + their lines. +

    +
      { targets.map((t) => ( -
    • +
    • {t} - {ran.has(t) && run every build} + {ran.has(t) ? ● runs : ○ compiles}
    • )) }
    - {/* The audit is not universal and this sentence used to say it was: - * "the rest type-check and have the object they emit audited" covered - * two triples README.md says are type-checked only. Both the count and - * the exceptions are read out of README.md by scripts/targets.mjs now, - * so neither is typed here and a change to that row moves this - * sentence. */} -

    - The {ranCount} that run are {ranHow.join(", ")}. The wasm32 build is one - self-contained module, which is why every program on the{" "} - tour runs in your browser. Of the{" "} - , {auditedCount} have the object they emit - audited for what it asks the machine for;{" "} - { - exempt.map((t, i) => ( - <>{i === 0 ? "" : i === exempt.length - 1 ? " and " : ", "}{t} - )) - }{" "} - are not. Windows is not in the {ranCount}, and is no longer only - compiled for either: {onWindows.length} jobs execute on a Windows - machine every build, and{" "} - {gatedOnWindows.length === onWindows.length ? "every one" : `${gatedOnWindows.length} of them`}{" "} - is on the list a tag waits for, so a failure there stops a release. The{" "} - platforms page names them with their - lines. -

    - -
    - - {/* 4. Performance & efficiency ---------------------------------------- */} -
    -
    -

    4. Performance and efficiency

    -

    Native code, a small program, and a front end that answers before you notice.

    -

    - iyi generates native code through LLVM. At run time the two libraries - are within noise where they do the same work. What changes is what a - program carries: it links only what it uses, and iyi's own - library is lines against Crystal's{" "} - {lib.crystal.toLocaleString("en-GB")}, {" "} - on disk beside the binary. -

    - -
    +
    + +
    +
    +

    4 · Performance and efficiency

    +

    Native code, a small program, and a front end that answers before you notice.

    +

    + iyi generates native code through LLVM. At run time the two + libraries are within noise where they do the same work. What changes + is what a program carries: it links only what it uses, and iyi's own + library is lines against Crystal's{" "} + {lib.crystal.toLocaleString("en-GB")}, {" "} + on disk beside the binary. +

    +
    +
    -
    -
    -
    -
    - iyi - {bin.iyi.kb} KB - starts in {bin.iyi.ms} ms -
    -
    -
    -
    -
    - Crystal's library - {bin.crystal.kb.toLocaleString("en-GB")} KB - starts in {bin.crystal.ms} ms -
    -
    + +
    + +
    +
    +
    iyi
    +
    {bin.iyi.kb} KBstarts in {bin.iyi.ms} ms
    +
    +
    +
    Crystal's lib
    +
    {bin.crystal.kb.toLocaleString("en-GB")} KBstarts in {bin.crystal.ms} ms
    +
    +
    -

    - The same puts "hello", drawn at true area. A difference - this large rendered as length would flatter it. -

    - - - -

    - Parse, check, and refuse or accept, before any code is generated. - That is what iyi check and the language server pay per - question, and it is why an editor and an agent can ask the compiler - on every keystroke. -

    +
    + +

    + The front end answers hello: parse, check, and refuse or accept, + before any code is generated. It is why an editor and an agent + can ask the compiler on every keystroke. +

    +
    + +

    + at building strings, with the collector off. As it runs, the same + workload is {stringRow.as_it_runs.toFixed(2)}x, within noise. The + collector masks a slower builder. Cold builds of unseen code are + slower; the speed is on the edit loop. +

    +
    +
    - -

    Where it loses

    - -

    - As it runs, the same workload is {stringRow.as_it_runs.toFixed(2)}x, - within noise. The collector masks a slower builder. Cold builds of - unseen code are slower; the speed is on the edit loop, which is the - row that matters. -

    -
    - {/* README.md's own "Where it loses" and the paragraph after it, said in - * the same terms: the prelude's floor, where sockets actually live, - * which platforms concurrency runs on, and what dependencies do not - * have yet. Written looser than that once already - "no sockets ... - * yet" read as though the language had none, when std/socket is in the - * tree and it is the prelude that stops short. */} -

    +

    Young parts, named: iyi's own prelude carries the small-tool floor and no more, so it has no TLS and no serialization, and sockets and format strings live in std/socket and std/format,{" "} @@ -456,357 +356,427 @@ const untouched = SPELLED[rest] ?? String(rest); pin, but there is no registry and no artifact distribution yet, and{" "} --crystal still leans on shards.

    -
    -
    - -
    -
    -

    Check it yourself.

    -

    - The tour runs in your browser. The lessons run real programs from the - repository. And every number above is one of two things: counted from - the tree, where it is the same on your machine as on ours, or recorded, - in which case it carries the command that prints it and, where the - number is a duration, the machine it ran on. +

    + +
    +

    Check it yourself.

    +

    + Every number above is one of two things: counted from the tree, where + it is the same on your machine as on ours, or recorded, in which case + it carries the command that prints it and, where the number is a + duration, the machine it ran on.

    - -
    + + + + diff --git a/src/playground/samples.ts b/src/playground/samples.ts index 3f7156a..67ab384 100644 --- a/src/playground/samples.ts +++ b/src/playground/samples.ts @@ -41,6 +41,7 @@ * `src/lib/highlight.ts` instead. A site arguing that a program should link * only what it uses does not get to ship either one. */ +import { recordedText } from "../lib/highlight"; import manifest from "../../records/wasm/manifest.json"; import toc from "../../samples/tour/tour.json"; @@ -279,3 +280,12 @@ export function sampleNote(pathOrId: string): string | null { return findSample(pathOrId)?.note ?? null; } +/** + * What a program says it is: the first line of its own leading comment, + * without the `#` and without a closing full stop, so a caller adds the + * punctuation its sentence needs. Read out of the recording, so the + * description cannot drift from the file. Markdown, as written in the source. + */ +export function blurbOf(path: string): string { + return recordedText(path).split("\n")[0].replace(/^#\s*/, "").replace(/\.$/, ""); +} diff --git a/src/styles/base.css b/src/styles/base.css index 83e1c56..bdb0627 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -16,9 +16,24 @@ body { color: var(--ink); font-family: var(--font-text); font-size: var(--step-0); - line-height: var(--lead-body); + line-height: 1.5; font-variant-numeric: tabular-nums; -webkit-font-smoothing: antialiased; + overflow-x: hidden; +} + +html { + scroll-behavior: smooth; + scroll-padding-top: 72px; +} + +img { + max-width: 100%; +} + +::selection { + background: var(--brand); + color: var(--on-brand); } /* The page container: one width, centred, for headers and the wide marketing @@ -80,22 +95,33 @@ h3, h4 { font-weight: 700; line-height: var(--lead-tight); - letter-spacing: -0.015em; + letter-spacing: -0.04em; text-wrap: balance; margin: 0; } +/* The display sizes are set a little wide: Mona Sans's width axis at 106% and + * 108% is what gives the headings their weight without going to black. */ h1 { font-size: var(--step-4); + font-stretch: 106%; } h2 { font-size: var(--step-3); + font-weight: 750; + font-stretch: 108%; } h3 { - font-size: var(--step-2); + font-size: var(--step-1); + font-weight: 650; + letter-spacing: -0.02em; + line-height: var(--lead-snug); } h4 { - font-size: var(--step-1); + font-size: var(--step-0); + font-weight: 650; + letter-spacing: -0.01em; + line-height: var(--lead-snug); } p { @@ -134,8 +160,7 @@ blockquote a, } a:hover { - text-decoration: underline; - text-underline-offset: 0.18em; + color: var(--ink); } strong { @@ -148,15 +173,24 @@ kbd, samp, .mono { font-family: var(--font-mono); - font-size: 0.885em; + font-size: 0.9em; + font-variant-ligatures: none; +} + +/* A `pre` gets the browser's own `monospace` unless it is told otherwise, and + * it does not inherit the face from its frame: an editable listing or a run's + * output with no `code` inside was being drawn in the system's Courier. The + * size is its frame's, so only the family is set here. */ +pre { + font-family: var(--font-mono); font-variant-ligatures: none; } :not(pre) > code { background: var(--raise); border: var(--hair) solid var(--hairline); - padding: 0.08em 0.32em; - border-radius: 4px; + padding: 1px 6px; + border-radius: 6px; white-space: nowrap; } @@ -172,35 +206,63 @@ samp, /* Buttons ---------------------------------------------------------------- */ -.cta { +/* Three buttons: the red one for the thing to do, the ink one for the + * thing to do from a footer, and a ghost for the second choice. `.cta` is the + * older name of the red one and is kept so a page not yet moved still draws. */ +.cta, +.button { display: inline-flex; align-items: center; - gap: 0.5rem; - padding: 0.7rem 1.25rem; + gap: 8px; + height: 44px; + padding: 0 18px; border-radius: var(--radius); background: var(--brand); - color: var(--paper); - font-weight: 600; - font-size: var(--step-0); - line-height: 1.2; + color: var(--on-brand); + font: 600 15px var(--font-sans); + line-height: 1; text-decoration: none; border: var(--hair) solid transparent; - transition: background-color 120ms ease; + width: fit-content; + cursor: pointer; } -.cta:hover { - background: var(--brand-deep); +.cta:hover, +.button:hover { + color: var(--on-brand); + filter: brightness(1.08); text-decoration: none; } -.cta.ghost { - background: transparent; +/* An author `display` beats the user agent's `[hidden] { display: none }`, so + * a button marked hidden would stay on the page and stay clickable. */ +.cta[hidden], +.button[hidden] { + display: none; +} + +.button--ink { + background: var(--ink); + color: var(--paper); +} + +.button--ink:hover { + color: var(--paper); + filter: none; + opacity: 0.88; +} + +.cta.ghost, +.button--ghost { + background: var(--card); color: var(--ink); border-color: var(--boundary); } -.cta.ghost:hover { - background: var(--raise); +.cta.ghost:hover, +.button--ghost:hover { + color: var(--ink); + filter: none; border-color: var(--graphite); } @@ -221,14 +283,12 @@ samp, /* A copy control, sitting in a block's header. */ .copy { margin-inline-start: auto; - font-family: var(--font-sans); - font-size: var(--step--2); - font-weight: 600; + font: 500 12px var(--font-sans); color: var(--graphite); - background: var(--paper); + background: var(--card); border: var(--hair) solid var(--boundary); - border-radius: 6px; - padding: 0.2rem 0.6rem; + border-radius: 7px; + padding: 4px 10px; cursor: pointer; letter-spacing: 0; text-transform: none; @@ -492,7 +552,13 @@ samp, * is a figure this reader never sees, so every settled state is the default in * CSS and motion is what gets removed. */ @media (prefers-reduced-motion: reduce) { - .cta { - transition: none; + *, + *::before, + *::after { + transition: none !important; + animation: none !important; + } + html { + scroll-behavior: auto; } } diff --git a/src/styles/code.css b/src/styles/code.css index d1ce6cd..c7375c9 100644 --- a/src/styles/code.css +++ b/src/styles/code.css @@ -5,17 +5,18 @@ * The highlighting theory: emphasise exactly the tokens that are iyi's and not * Crystal's. `module`, `import`, `pub`, `trait`, `impl`, `forall`, `derive`, * `type` are the whole delta, and they are what a reader is here to - * see. Everything else is ink. There is no rainbow, because a rainbow would - * spend attention on syntax that is not the argument. + * see. Three hues and no more: keywords in the accent, strings in the green, + * comments receding. Everything else is ink. */ .source { font-family: var(--font-mono); - font-size: var(--step--1); - line-height: 1.58; - background: var(--raise); + font-size: 13px; + line-height: 1.7; + background: var(--code); border: var(--hair) solid var(--hairline); - border-radius: 2px; + border-radius: 14px; + overflow: hidden; margin: 0; tab-size: 2; } @@ -30,7 +31,7 @@ .source pre { margin: 0; - padding: 0.95rem 1.1rem; + padding: 16px 18px; overflow: visible; } @@ -49,16 +50,17 @@ align-items: baseline; justify-content: space-between; flex-wrap: wrap; - gap: 0.3rem 1rem; - padding: 0.45rem 1.1rem; + align-items: center; + gap: 0.3rem 12px; + padding: 10px 12px 10px 16px; border-bottom: var(--hair) solid var(--hairline); font-family: var(--font-mono); - font-size: var(--step--2); + font-size: 12px; color: var(--mute); } .source-head .path { - color: var(--graphite); + color: var(--mute); } /* Tokens ----------------------------------------------------------------- */ @@ -79,10 +81,9 @@ * primitive literal c a comment * i the delimiters of a string interpolation * - * Weight only, and no hue anywhere in here. It survives printing and colour - * blindness, and more importantly it keeps the signal colour free for the one - * thing the signal colour means on this site, which is a loss or a caveat. - * Syntax is never a loss. */ + * Keywords take the accent and strings the green, and the nine rules below + * keep their extra weight on top of that, so the delta still reads in a + * printout or to a reader who cannot tell the two hues apart. */ /* THE ONE EMPHASIS THAT MATTERS. `module`, `import`, `pub`, `trait`, `impl`, * `forall`, `derive`, `type` and `abstract` are the entire delta @@ -111,13 +112,13 @@ .tok-rule, .k.tok-rule { font-weight: 600; - color: var(--ink); + color: var(--brand-deep); } /* An ordinary Crystal keyword. Present, and quieter than the nine above. */ .k { font-weight: 500; - color: var(--ink); + color: var(--brand-deep); } /* A constant or a type name. Ink, unweighted: a type is structure, and in iyi @@ -133,10 +134,12 @@ color: var(--ink); } -/* Literals recede. A string, a character, a number, a symbol, `nil`, `true` - * and `false` are the least interesting thing in a listing about a type - * system, and graphite puts them behind the structure without hiding them. */ -.s, +/* A string or a character is the green; a number, a symbol, `nil`, `true` + * and `false` recede to graphite behind the structure. */ +.s { + color: var(--ok); +} + .n { color: var(--graphite); } @@ -155,13 +158,10 @@ color: var(--mute); } -/* A comment. Mute italic, and the mono's true italic is the reason IBM Plex - * Mono was chosen over the ligature monos: prose inside code should read as - * prose. Comments in samples/iyi/ carry the rule citations, so they are - * quiet but never illegible. */ +/* A comment. Mute and upright. Comments in samples/iyi/ carry the rule + * citations, so they are quiet but never illegible. */ .c { color: var(--mute); - font-style: italic; } /* Declaration face ------------------------------------------------------- */ @@ -177,13 +177,11 @@ gap: 0.45rem; font-family: var(--font-mono); font-size: var(--step--2); - letter-spacing: 0.06em; - text-transform: uppercase; color: var(--graphite); - background: none; + background: var(--card); border: var(--hair) solid var(--boundary); - border-radius: 2px; - padding: 0.2rem 0.5rem; + border-radius: 7px; + padding: 4px 10px; cursor: pointer; } @@ -214,43 +212,41 @@ /* Console ---------------------------------------------------------------- */ -/* A console block is a recording of a real run, so it gets the terminal frame - * the README's own chart uses, and nothing else on the site does. Output is - * never syntax highlighted, because nothing highlighted it in the terminal. */ +/* A console block is a recording of a real run. It is a code block on the + * raised ground, with a caption bar naming what ran. Output is never syntax + * highlighted, because nothing highlighted it in the terminal. */ .console { font-family: var(--font-mono); - font-size: var(--step--1); - line-height: 1.6; + font-size: 13px; + line-height: 1.7; background: var(--raise); border: var(--hair) solid var(--hairline); - border-radius: 6px; + border-radius: 14px; overflow: hidden; } .console-chrome { display: flex; - gap: 0.45rem; + gap: 12px; align-items: center; - padding: 0.55rem 0.8rem; + padding: 10px 12px 10px 16px; border-bottom: var(--hair) solid var(--hairline); + font-size: 12px; + color: var(--mute); } .console-chrome i { - width: 9px; - height: 9px; - border-radius: 50%; - background: var(--hairline); + display: none; } .console-chrome .caption { - margin-inline-start: 0.5rem; - font-size: var(--step--2); + font-size: 12px; color: var(--mute); } .console pre { margin: 0; - padding: 0.9rem 1.1rem; + padding: 16px 18px; white-space: pre-wrap; overflow-wrap: anywhere; } @@ -266,7 +262,7 @@ } .console .out { - color: var(--graphite); + color: var(--ink); } /* Diagnostics ------------------------------------------------------------ */ @@ -276,13 +272,13 @@ * exactly as the compiler prints it. */ .diagnostic { font-family: var(--font-mono); - font-size: var(--step--1); - line-height: 1.55; - background: var(--raise); + font-size: 13px; + line-height: 1.7; + background: var(--code); border: var(--hair) solid var(--hairline); - border-left: 2px solid var(--signal); - border-radius: 0 2px 2px 0; - padding: 0.9rem 1.1rem; + box-shadow: inset 2px 0 0 var(--brand); + border-radius: 14px; + padding: 16px 18px; overflow-x: auto; } diff --git a/src/styles/site.css b/src/styles/site.css new file mode 100644 index 0000000..56c5faf --- /dev/null +++ b/src/styles/site.css @@ -0,0 +1,449 @@ +/* The shared pieces of the page: type, sections, cards, code frames, tabs, + * chips and tables. Anything only one page draws lives in that page's own + * scoped