feat: give the site an information architecture - #8
Draft
rvanbaalen wants to merge 22 commits into
Draft
rvanbaalen wants to merge 22 commits into
rvanbaalen wants to merge 22 commits into
Conversation
The site was one page plus a 404, and privacy and terms had just been bolted on. Adding a bezel explainer and a how-to the same way would have left three pages competing with their own homepage sections. A page now exists only where it serves an intent the homepage cannot. The homepage still answers "what is this and should I install it" and is unchanged. Five new pages answer the rest: - /help is canonical for installing and troubleshooting. The Gatekeeper instructions existed in three places and I had already given wrong ones out of the README copy, so Download.astro now points here instead of repeating them. - /project is the transparency page: who, why it is free, and the things a reader can verify rather than trust. - /guides is a hub, with the bezel-compensation write-up aimed at developers rather than restating the homepage section, and a how-to targeting the search query people actually type. - /alternatives compares fairly, including where SpreadPaper is behind. TextPage.astro and Prose.astro carry the shared shell and typography so these pages do not each reinvent a header. The nav was built for one page: bare fragments plus a scrollspy. Product anchors now resolve to /#anchor away from the homepage and stay bare on it, so the spy still works. The footer carries a site index. .cd-code was only ever scoped inside Download.astro, so the class was inert anywhere else. It moves to style.css.
The page was eleven sections built from search queries. Checking the issue tracker found three problems anyone has ever reported: the Gatekeeper dialog, and two open bugs of our own. Documenting a workaround for your own unfixed bug is a bug report in a costume, so the page is now the dialog, the requirements, and where to ask. Copy went from 806 rendered words to 397. The warning is quoted verbatim with ".app" inside the quotes, because that is the string people paste into a search box, and the page says plainly that Move to Trash is the blue default button, which it is. Three Apple facts were verified first hand rather than taken from secondary sources: Open Anyway lasts about an hour, it asks for a login password rather than an administrator one, and there is no second launch. A reviewer had reported the second launch; Apple's own page contradicts it. Layout went through four attempts before this one. DESIGN.md records all four and why three failed, so nobody rebuilds them. What holds: one left edge, two widths, prose at 704px and everything else at the 1216px band, which is the nav's own left edge. Screenshots are cropped to their subject rather than scaled, after a 1444px capture of a nearly empty folder was rendered full width. Two silent failures got guards. Astro drops the space before an inline tag, which had already shipped to production as "Ask at<a href". And a stray comment fragment deleted an entire CSS rule while the build stayed green, every page returned 200, and astro check passed.
The contents card indexed three headings visible on the same screen, one of them a restatement of the h2 below it. Requirements gave four short values a section heading and 64px of air. And every section opened with a rule plus 120px regardless of what followed, which put a 704px rule 100px above a 1216px row of values with hairlines of its own. Header rule to the first heading: 583px to 254px.
The page explained at length what had not happened rather than what to do. It opened "Nothing was scanned and nothing was found", four clauses about absences, on a page someone reaches when they are stuck. Rewritten from nothing: 806 rendered words to 260. Every sentence is now either an instruction or a fact. The three screenshots carry the steps they illustrate.
Chosen from three prototypes: editorial, guided and split. Split won. Words on the left, the screenshot that proves them on the right, holding position while you read past it. A rule under each step, and a numbered chip beside the step's own label rather than floating above it. The fork is now explicit. Steps one and two are sequential, then System Settings or Terminal. Numbering all four in sequence told the reader to do both. The Terminal step has no screenshot, so it runs full width rather than leaving half a row empty.
The copy and check glyphs swapped by toggling hidden, which is display:none and teleports. They now share one grid cell and cross fade on opacity and scale, 140ms ease-out, driven by data-copied on the button. The markup still ships the check glyph hidden, which is the correct no-script state; setupCopyButtons drops that attribute once it has a clipboard to write to. Both glyphs carry aria-hidden so a screen reader hears the label once. Reduced motion keeps the opacity fade and drops the scale.
It was markup in the page, styles in the global sheet and behaviour in main.ts, with the confirmation swapped by toggling hidden. That is display:none, so it could never animate. Now one component. The two faces stack in a single grid cell, so the button is as wide as its widest label and never resizes mid animation. On success they roll upward: the old face leaves through the top, the new arrives from the bottom, 260ms ease-out, with the confirmation in the success green and a matching tint on the button itself. The text to copy is a prop rather than a DOM lookup, so the button cannot end up pointing at an element that moved or was renamed. Tests rewritten with it. The old ones evaluated main.ts alone; the component ships its own inline module, so they now run every module the page carries.
Two lines were set in the success green to mark the end of each route. It read as decoration rather than meaning, so the class and its rule are gone and both are ordinary paragraphs. The copy button keeps its green, where it confirms an action the reader just took rather than colouring a sentence they are reading.
Six components each chose their own curve and duration: 120ms ease on a button press, 150ms ease-out on links, 250ms on the scroll reveal, and the copy button on a cubic-bezier nothing else knew about. There is now one --ease-out token and everything uses it. The button press was translateY(1px), which is below the threshold of feeling like a press. It is scale(0.97) at 160ms, which also takes the label and glyph down with it. Twelve hover states were ungated. On a touch device hover fires on tap and stays after the finger leaves, so every one now sits behind (hover: hover) and (pointer: fine). The mobile nav link needed its aria-current styling split out first, or the current marker would have gone with the hover. The scroll reveal staggered by setting the same 60ms delay on every element after the first, so they still arrived together. It is now index * 40ms, capped at four. And the reduced-motion block targeted [class*=animate-], which matches nothing here. It now names the properties that actually move.
The bar carried the four homepage sections, so every link on /help, /guides and /project was a trip back to the homepage, and the pages the site has grown since the IA work were reachable only from the footer. It now lists Features, Help, Guides and Project on every page, in three zones: the wordmark left, the links on the optical centre, Star and Download right. The page you are on is marked with a filled pill instead of an underline, matched by path prefix so a guide still marks Guides. The scroll spy goes with the section links it existed to mark. Nothing in the nav points at a section any more except Features, which is the page you are already on when it does.
The credits were six sentences of the shape "Subject, by Name on Unsplash" in a three column grid, which named everyone correctly and showed nothing. They are now the photographs themselves: the six bands laid end to end as one panorama with the page showing through 2px seams, which is what the app does to a picture, and each photographer named under the band they shot. A segment links to its photograph, a name links to its photographer. The six files moved into an array in the frontmatter, so the strip and the names are built from one source in one order. That is the only thing keeping a photographer under their own picture, so the tests now check every band against the name below it, and that each file was published. The lede said "every photograph on this page", which was wrong on a page carrying none. It says "on this site and in the app".
The last block was a copyright sentence over a row of seven links, three of which now sit in the top nav on every page. Repeating Help, Guides and Project there meant three of the seven pointed at a bar the reader could already see. It is one row now: the sentence on the left, the four links that live nowhere else on the right, both on the same baseline at the smallest size the footer uses. Alternatives stays because this row is still the only link to it anywhere on the site, which is also why the tests now check all four are present and that the three the nav carries have not come back. The year comes from the build rather than a string, so it never has to be a commit.
Both link columns pointed at GitHub, all eight of them, so the footer had nowhere to put a page belonging to this site. That is why /alternatives ended up hanging off the last line on its own. The columns are grouped by where a link goes now: This site carries Help, Guides, Project, Alternatives, Privacy policy and Terms of service; The code carries the repository, releases, changelog and issues; Around it carries show and tell, credits, the security policy and the licence. The site's own pages come first, because a reader at the bottom of a page is on this site rather than on GitHub. The last line drops to what is left of it: the copyright, which now links Robin van Baalen to robinvanbaalen.nl, and the address. The tests check each of the six pages is linked from the footer exactly once, so neither a move nor a duplicate passes quietly.
The hero described the app in four sentences and put the picture of it last, below the fold on a laptop. The two buttons sat under that paragraph in the right hand column. The picture is now a switch. Two rigs sit on top of each other, the lower one carrying the photograph per screen the way macOS hands it over and the upper one carrying it across them, and turning SpreadPaper on wipes the upper one across the desk from the left. 420ms, longer than the site's other transitions, because this one explains rather than acknowledges and the eye needs an edge to follow. The buttons moved into a download card beside the headline, which also answers the question that comes with them: version, download size, the macOS it needs, and the licence. Those come from the releases API at build time through src/lib/release.ts, the way the star count already works, because a version typed into a component goes stale without anyone noticing. Checking the real release turned up that the disk image is 74 MB. The stage ships spanned and the switch ships hidden, so a reader with no JavaScript gets the desk the app makes and never sees a control that cannot do anything. Two fixes on the way. The repeated layer clipped each screen separately, which draws correctly and breaks the rig contract, since rigs-check reads the first clipPath and expects every screen in it; an image already draws only inside its own box, so the separate clips were doing nothing. And the homepage now carries two inline scripts, so the test picking the first one it found was testing the hero and silently skipping the page script. It runs every script now, each in its own scope.
Comments across the files this branch touched narrated how the code got here rather than what it does: which layout the nav carried before, that both footer columns pointed at GitHub, where the hero buttons used to sit. That history rots. Git blame is its home, not the source file. Every one of them now describes the present, and the section banners and multi line rationale blocks come down to the one or two lines of why that carry value. src/lib/release.ts gains real docblocks with prose ahead of the tags. Two long docblocks in Prose.astro and TextPage.astro stay as they are, deliberate and worth the space. Only their history sentences are gone.
The editor section drew a still canvas and described dragging a photograph across it. It now hands that over. Add the displays you own from a menu of six real panels, drag them into the shape of your desk, drop a picture on the canvas or choose one from disk, then place it with the same zoom, fit and flip the app's own HUD draws. Everything stays on the reader's Mac.
Screen sizes move to src/lib/displays.ts, which scripts/rigs.ts now reads too, so a 27-inch is 355 by 200 whether a rig draws it at build time or the bench draws it in the browser. Running `npm run rigs` after that move regenerates RIGS.md and rigs-preview.html byte for byte.
The section still reads with no JavaScript. The generated rig-mixed canvas ships in the markup and is the whole figure until the script takes it down, which also keeps a real rig in the .astro for rigs-check.ts to hold to the rig contract.
Its styles are global rather than scoped. Astro scopes a component's CSS by rewriting every selector to carry a data-astro-cid attribute and stamping that attribute on the elements the component renders, and the bench renders none of its own: a scoped `.bn-hit { fill: transparent }` reached nothing, and an SVG rect with no fill is black, one over every display. DESIGN.md records the trap under Overriding the primitives and three checks in the page tests hold the built CSS to it.
Not one pressable thing in the bench acknowledged a click. Zoom, Fit, Flip, remove, the mode segments, the four thumbnails and the panel menu all took a press in silence, on a page whose own button primitive has scaled on :active since the motion pass. All seven now scale to 0.97 over 160ms on the shared curve. Three other moments gained motion with them: the add-display menu grows from the button under it rather than appearing beside it, a display that was not on the list a moment ago arrives rather than materialising, and the drop target fades up instead of snapping on. Zoom eases too, which took a registered custom property to do honestly. draw() rebuilds the group carrying the transform on every pointer event, so a transition written there has nothing to run from. --bn-zoom is declared as a number on the canvas, which outlives the rebuild, and a rebuilt group picks the value up mid-flight. Pan stays literal in the transform string, because it changes every frame of a drag and must not lag, and a drag drops the easing entirely. Only a genuinely new row animates: a rebuild recreates every row, so the keys already on screen are remembered and the rest arrive without moving. Twelve checks cover it, read off the compiled stylesheet rather than the source, since scoped CSS silently never reaches markup a script builds. Each was confirmed able to fail. The first version of the registered-property check could not: it matched a substring, so renaming the property to --bn-zoom-DISABLED still passed it. It matches to the brace now. Snap, guides, and the swap from the still canvas at boot stay instant. A snap that eases is a snap you cannot feel, a guide that fades in arrives after the thing it explains, and the section has to be complete in its first frame.
The row entrance shipped inert. Its two properties were never in a transition list: the rule that names transform sits on the buttons, and the row keeps its own rule transitioning colours only, so releasing the entering state snapped rather than moved. The row now names transform and opacity itself, and travels 6 units rather than 4, which is visible at the size a row is. A second bug rode along. On the first build there is no previous key set, so every row counted as new and the whole list would have animated as the bench mounted. The bench arriving is not a display being added, and the section has to be complete in its first frame, so the first build marks nothing. One of the two checks added with this is gone rather than kept green. It asserted the first build marks no row, which it cannot: the marker is cleared on the next animation frame, long before the assertion reads it, so it passed whether the guard existed or not. Sabotaging the guard proved that. What remains is the pair that does fail when broken, which is that adding a display marks exactly one row and it is the new one.
Removing a display took its row off the list in one frame and the rows under it jumped up to fill the space. The row now leaves. State is not held up for it. The display is gone from the desk the moment the button is pressed, and a copy of the row stays behind to collapse: measured first, since a grid row has no height to collapse from once it is out of one, stripped of its data-row so nothing can select it, hidden from assistive tech and taken out of the tab order. Height is the one property here that is not a transform, which is what closing a gap costs. Comments across the change come down to the why. Several narrated what the code used to do or walked through what the next block does, which is what git blame is for. One docblock is rewritten to carry its parameters and stay inside the line rule; the validator is clean. The test file's own duplicate reads of the compiled CSS and JS are gone, reusing the two collections already at the top.
The section had words at three different left edges: the heading at column one, the lead bottom aligned at column eight, and a caption back at column one under the bench. Bottom aligning a paragraph to a three line heading opens a hole beside it that nothing fills. Heading and lead now sit in one column at one edge, the way the text pages are set, and the lead loses the instruction it was carrying, since the bench is right there and labels itself. The caption is gone rather than reworded. It explained why the desk is described by hand and that nothing leaves your Mac, neither of which a reader of a demo asked about, and it sat between the bench and the three points that close the section. Those three lose about a fifth of their words. This is a showcase, not a manual.
The canvas opened on the half size copy of hero-day-2. The picture is drawn about 1490 CSS pixels wide there, so a 1200 pixel source gives 0.81 device pixels per CSS pixel, and 0.40 on a Retina Mac. Visibly soft, on the largest photograph the page shows after the hero. hero-day-2 is pinned to the original now, so the canvas, the still rig behind it and the Types day cycle all draw the same 2400 pixel file. One URL per photograph still holds, and Types pays 44KB below the fold for it. Worth recording while looking: rigs-check reads literal href and src attributes, so it never sees the footer strip, which passes its URLs through an expression and uses a third size of six photographs. The rule it enforces is narrower than the rule it states.
Two buttons reading "Arrange displays" and "Place the picture" named two things without saying what picking one did, and they made the mode control the widest object in a strip of 38px glyphs. It reads "Drag [Displays | Wallpaper]" now. The verb is said once, in front of the track, and each option is the thing that verb acts on, so the control answers the question it exists to ask. It is also built as the control it is. Two aria-pressed buttons in a group are two independent switches; this is one of two, so it is a radiogroup: one tab stop, arrows and Home and End inside it, focus travelling with the selection, and a thumb measured in the render since the labels are different widths. The track is inset and the selection raised out of it, because the old tinted state was one grey against another. The word is wallpaper throughout the section. The panel, the file picker, the hint and the alt text all said picture, for the one thing the app exists to make. The hero keeps "one picture", where it is the deliberate contrast to every monitor having its own wallpaper. Nine checks cover the radiogroup: the roles, one checked option, one tab stop, both arrows moving selection and focus and canvas together, and the thumb being positioned at all.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft. The site was one page plus a 404, with privacy and terms bolted on this afternoon. Adding a bezel explainer and a how-to the same way would have produced three pages competing with their own homepage sections for the same queries, so this establishes a structure first and then fills it.
The rule
A page exists only where it serves an intent the homepage cannot. Not a topic, an intent. The homepage still answers "what is this and should I install it" and is unchanged apart from one section.
/unchanged/help/project, plus the existing/privacyand/terms/guides/span-wallpaper-across-two-monitors-mac/guides/bezel-compensation/alternativesDeliberately not built: a page per feature, those are homepage sections; a blog with nothing to put in it; a docs site for an app with one window; a page per competitor, which reads as spam at this size.
The pages
/helpis the one that fixes a real bug rather than chasing traffic. The Gatekeeper instructions existed in three places and had already drifted: the README still tells people to right click and Open, which Apple removed in macOS 15. This is now canonical andDownload.astropoints at it instead of repeating it./projectis the transparency page. One person, no company, why it is free, and the things a reader can check rather than take on trust./guides/bezel-compensationis written for developers rather than as a longer version of the homepage Bezels section. It works through why slicing on the display boundary fails, sampling in physical space instead, and a worked example: two displays at 597 mm across a 16 mm gap, 7680 px at about 6.35 px/mm, giving slices of 0 to 3789 and 3891 to 7680 and a discarded strip of 102 px. The arithmetic reconciles from both directions and 3789 + 102 + 3789 = 7680 exactly, which matters because this is the page most likely to be checked with a calculator./guides/span-wallpaper-across-two-monitors-mactargets the query people actually type, and is honest that doing it by hand in an editor works too./alternativesincludes a "Where SpreadPaper is behind" section. The App Store apps are signed and notarised and this is not, which is a real advantage and is stated plainly.Structural changes
TextPage.astroandProse.astrocarry the shared shell and typography, so seven prose pages do not each reinvent a header block.The nav was built for a single page: bare fragments plus a scrollspy. From
/help, a bare#editorscrolled nowhere. Product anchors now resolve to/#anchoraway from the homepage and stay bare on it, so the spy still reads them. The footer carries a site index..cd-codehad only ever been a scoped style insideDownload.astro, so the class was inert on every other page and the terminal command was rendering in the body font. It moves tostyle.css.Tests
npm run checkclean over 31 files,npm testall passing.The copy button was removed at one point, correctly in isolation, since its only call site was the block being deleted. It is restored and wired to
/help, because thexattrcommand is the single most copy-worthy string on the site. Its tests came back with it and five more: jsdom ships nonavigator.clipboard, so the original test only ever exercised the refusal path and the button's actual behaviour was never covered. A stubbed clipboard now asserts it appears, copies the trimmed command, swaps its label and swaps its glyphs.Deleting that function also took the script bundle under Astro's 4 KB threshold, so Astro stopped emitting
_astro/*.jsand inlined it, and the test that globbed for the file crashed onundefined. It now reads the bundle from wherever the build put it, which restoring the button promptly exercised in the other direction.Worth a second opinion
/alternativesnames competitors and characterises them. Worth reading for fairness./privacyand/termsfrom this afternoon are wrapped at about 100 columns. I left that inconsistency rather than risk a scripted reflow, which brokehelp.astroonce already. Separate pass.