Skip to content

Motion and visual upgrade across both sites, and the audit fixes under it - #109

Merged
Hotragn merged 11 commits into
mainfrom
design/motion-visual-upgrade
Sep 18, 2026
Merged

Hotragn merged 11 commits into
mainfrom
design/motion-visual-upgrade

Conversation

@Hotragn

@Hotragn Hotragn commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Nine phases of motion and visual work across both sites, plus the audit fixes
underneath them. Reviewed locally against a production build; nothing here has
been near production.

The audit fixes, first

Every canonical and og: tag on apps/web resolved to http://localhost:3000
in production — homepage, /pricing, /manifesto, the 404. One
?? "http://localhost:3000" reached whenever NEXT_PUBLIC_SITE_URL is unset,
and setting it in the dashboard fixes that exactly once.

The chain is now NEXT_PUBLIC_SITE_URL, then VERCEL_PROJECT_PRODUCTION_URL,
then VERCEL_URL, then localhost. Verified by building with
VERCEL_PROJECT_PRODUCTION_URL=husk.dev: canonical, og:url, the robots host
and every sitemap entry came out https://husk.dev.

You still need to set NEXT_PUBLIC_SITE_URL in both Vercel projects. The
fallback stops this class of bug recurring; the explicit variable is the only
one that survives a custom domain, because Vercel's own keep naming the
.vercel.app host.

The docs site had no canonical and no og: tag on any page. #103 stripped a
trailing slash and #87 set metadataBase — which is what those tags resolve
against, not a substitute for declaring them.

<html lang> was not changed: it is present on both layouts and on
global-error, so the earlier report was reading pre-hydration HTML.

What is in it

Typography General Sans, Commit Mono, Newsreader on /manifesto only. next/font/local, instanced and subset before commit.
Hero A chat panel wired to a machine: the panel arrives, its messages land, the machine slides up, a cable draws, one pulse runs into the port and lights it.
Chat narrative A dock bottom-right. Open it and scroll inside the panel to scrub the transcript: the command types, sends, the tools attach, real output lands.
Providers The five options as a radial around the isolation viewer. The table is untouched and still above it.
husk.yaml A scroll-linked tilt on the frame. Content unchanged.
Footer Widened to four link columns that actually reach the docs. It already existed.
Hover previews 18 of them, both sites, each showing the destination's own share card.
Install selector Six clients, every command checked against that client's own docs.

Measured

Before After
Docs accessibility 96 100
Docs target-size fail pass
Docs canonical / OG absent present
CLS (4× CPU, Slow 4G) 0.00
LCP (same) 954 ms
Site-wide fonts 291 K 94 K

Marketing site: accessibility 100, SEO 100, best practices 96. The one failing
audit on both sites is /_vercel/insights/script.js 404ing on localhost, which
is an artefact of not being on Vercel.

Reduced motion was tested by forcing it, not by assuming: static SVG hero, the
transcript in the page flow at full opacity, launcher hidden, and neither
GSAP nor three.js downloaded at all
. All six prefers-reduced-motion CSS
rules confirmed present in the CSSOM.

The install commands are the part worth re-reading

Every one was read off that client's own documentation, with the source URL and
the date on each entry. Three would have been wrong by pattern-matching:

  • Antigravity is ~/.gemini/config/mcp_config.json. A number of write-ups
    say ~/.gemini/antigravity/...; Google's own IDE and CLI pages both say
    config.
  • Zed does not use mcpServers. Its key is context_servers, and its
    documented example carries an env object. Copying Cursor's shape here would
    have produced a file Zed ignores.
  • Cursor's deeplink base64-encodes the inner server object, not the
    {"mcpServers": {...}} wrapper. The encoder reproduces the worked example in
    Cursor's docs byte for byte, and the generated link was decoded back in the
    browser to check.

Gemini CLI is deliberately absent — retired in favour of Antigravity, so a
panel for it would send most visitors at a dead product.

Four hero objects, and why three were wrong

Worth recording, because each failed differently and the first three all
looked defensible:

  1. A lathed husk — a flat blob head-on. A surface with no wall has no
    inside to show.
  2. The mark extruded — solid and legible, and it was the logo, which is
    already in the header, the footer and the chat launcher.
  3. A ribbed seed pod with a real wall — a good object on its own terms, and
    still a seed rather than a computer.
  4. A chat wired to a machine — the headline, as an object.

§8.2 asks whether an object depicts anything. All four pass that. The first
three still failed, because "it depicts us" is not the same as depicting the
claim. That distinction is now written into the rule.

Where the code moved instead of the rules

Three things the brief asked for are not here, because the rule was right:

  • No shimmer on the footer wordmark. logo/USAGE.md bans decoration on the
    mark outright. Large was the part of the idea worth keeping.
  • The provider nodes do not orbit. They are click targets. A faint empty
    ring drifts instead.
  • Nothing idles. No breathing core, no blinking caret.

And one dependency went the other way: @react-three/postprocessing was
compositing an opaque frame behind a canvas the hero needs transparent. It is
uninstalled; the glow is one additive quad. Same look, ~100 K lighter.
ScrollTrigger came out too when the chat scrub moved inside the panel — only
gsap core is left, driven by timeline.progress().

Rule changes

brand/UI-PRINCIPLES.md §3 and §8 and BRAND.md §8 are amended. Every ban is
kept and narrowed to the reasoning it already gave, in the shape #98's revert
used. Two worth flagging for review:

  • §3 item 4 now permits loops and names every one. There are two: a
    spinner, and the provider orbit's ring. The ring is written down as
    decorative and as the first thing to cut.
  • §3 item 6 narrows the typewriter ban to time-driven. The original argument
    is unchanged and is what the amendment turns on.

BRAND.md goes to 1.1. The colour system, the mark and all four pillars are
untouched. drift-check 50 ok, --sites 32 ok.

Still open

  • Vendor logos on the client pills. Using five companies' marks means
    following five sets of usage guidelines; the pills carry text and a logo slot
    until those are sourced deliberately. A text pill that is right beats a mark
    that is stale or stylised.
  • One glyph. Commit Mono has no U+26A0, which husk run's approval prompt
    prints. The one place the docs quote that prompt shows one character in a
    fallback face. Quoted output stays quoted.
  • One line of existing copy changed. "There is no second step and no config
    file to edit" sat directly above a config file to edit for three of the six
    clients.
  • Timing has not been felt at 60fps. The review browser throttles
    requestAnimationFrame hard, so every sequence here is confirmed correct and
    confirmed to complete, but not judged for pace. The two knobs are
    OPEN_SECONDS in lib/hero-scene.ts and .nr-spacer { height } in
    globals.css.

…e faces

Two changes that both reach every page, and both touch the two layouts, so
they land together rather than as a commit that does not build.

## The site URL

Every canonical and og: tag on apps/web resolved to http://localhost:3000 in
production -- homepage, /pricing, /manifesto, the 404. The cause is one
`?? "http://localhost:3000"` reached whenever NEXT_PUBLIC_SITE_URL is unset,
and setting it in the dashboard fixes that exactly once: the next project, or
the next person who forgets, gets the same page.

The chain is now NEXT_PUBLIC_SITE_URL, then VERCEL_PROJECT_PRODUCTION_URL,
then VERCEL_URL, then localhost. The explicit variable still wins, because
Vercel's two keep naming the .vercel.app host after a custom domain is
attached. Both arrive bare, so the scheme is added here.

Neither VERCEL_ variable is NEXT_PUBLIC_, so both read undefined in a client
bundle -- and content.ts is imported by client components. SITE_URL moves to a
module only server code imports, and both files say why.

Verified by building with VERCEL_PROJECT_PRODUCTION_URL=husk.dev: canonical,
og:url, the robots host and every sitemap entry came out https://husk.dev.

The docs site had no canonical and no og: tag anywhere. #103 stripped a
trailing slash and #87 set metadataBase -- which is what those tags resolve
*against*, not a substitute for declaring them. Both routes now emit canonical,
og: and twitter: from the frontmatter each page already has.

## The faces

Bricolage Grotesque, Instrument Sans and JetBrains Mono are replaced by
General Sans (display and UI), Commit Mono (anything a machine wrote) and
Newsreader (the manifesto's body copy, and nothing else). BRAND.md section 8
still names the old three and is left alone until the change has been looked
at; if this stays, that table is what moves next.

Loaded through next/font/local, not hand-written @font-face: it fingerprints
the file, preloads per route, and synthesises a metric-matched fallback so the
swap does not reflow. Still nothing from a third-party origin -- the reason
there was never a next/font/google here has not changed.

The files were instanced and subset before being committed:

- Commit Mono's `ital` axis is instanced out; Newsreader is pinned to opsz 18,
  which takes it from 129K to 53K on its own.
- Subset to latin, plus -- for the mono face -- arrows, box drawing, block
  elements, geometric shapes and dingbats. Not decoration: the terminals on
  both sites print `- | + check x >=`, and a subset without them would render
  real output in a fallback face. Coverage was asserted against the glyphs the
  content actually uses before the originals were deleted.

92K site-wide against 291K for the three faces it replaces. Newsreader's 53K
is preloaded on /manifesto and nowhere else -- confirmed in the built HTML.

One gap: Commit Mono has no U+26A0, which `husk run`'s approval prompt prints.
The docs quote that prompt once, so one character there falls back. Quoted
output stays quoted.

tokens.css is byte-identical to brand/tokens.css and drift-check enforces it,
so the family tokens are re-pointed in each globals.css rather than edited
there. The five --font-display-opsz-* tokens are neutralised: they drove
Bricolage's optical-size axis and General Sans has only `wght`.

## Also

- The docs had no way back to the marketing site. Header and drawer both, since
  .header-nav is display:none below --bp-lg and a link that exists only at
  1024px is not a cross-link.
- Footer links were 20px tap targets, under the 24px target-size floor.
  inline-flex plus the standard control height moves the box, not the baseline.
- productionBrowserSourceMaps, and optimizePackageImports for three and
  @react-three/fiber, against the missing-source-maps and 146 KiB-unused audits.
- The 404 inherited the homepage's share card, so a dead link unfurled as the
  product. Its own title and description now.
- meta keywords is gone. No engine has read it since 2009.

Not changed: <html lang>. Present on both layouts and on global-error already.
The hero is two columns again. The left one is untouched -- headline, lead,
install command, both buttons, the meta line, at the widths those were
measured at. The right one is new.

## What the object is

The brand mark, extruded. brand/logo/USAGE.md calls it "a split husk with a
lit core" and Logo.tsx says of the two shell paths that "the asymmetry is
load-bearing"; husk-pod.ts takes those three path strings verbatim, maps the
32x32 viewBox into centred unit space, and extrudes them. Open is the mark
exactly: every offset reaches zero at the end of the sequence, so it arrives
at the logo rather than departing from one.

It plays once. Shut, the seam parts, the flap peels back on a pivot at its own
inner edge, the core rises and lights, a terminal glyph fades in beside it.
Then it holds and tracks the pointer within six degrees.

## Two builds thrown away, and why the second one mattered

The first revolved a husk profile and split the revolution. Head-on, an open
half-shell shows you its concave inside, which fills the silhouette as a flat
blob -- and the heavy half's arc covered the front, so the core sat directly
behind the piece that never moved. The object read as a closed almond with a
stripe on it. Hinging about Y instead of Z fixed which way it opened and not
the blob. Extruded outlines fixed both: solid pieces cannot swallow anything,
and the shape is the logo from the first frame.

Kept in the file as a comment. It is the kind of thing that gets rediscovered.

## What the previous attempt already knew

globals.css still carried the CSS for the HeroObject that e619faa deleted --
173 lines of component gone, its stylesheet left behind, including a live
`max-height: 18rem` that was overriding the new box to 288px.

Its comments were worth more than the CSS. The first layout here was 7/5 at
64rem, which put the install command behind a horizontal scrollbar at 1024 --
507px of mono in a 400px box. The orphaned block says, in as many words, that
7/5 "is how the command ended up truncated at 1152 in the first place", and
gives 8/4 at 72rem as the answer. That is what shipped. The command is the
highest-value line of copy on the site; it does not get a scrollbar twice.

## Budget

Three extrusions, a glyph and 48 points: about 1.1k triangles, four lights,
one bloom pass on the core's emissive alone, DPR capped at 1.75.

next/dynamic with ssr: false behind an IntersectionObserver, and the canvas is
*unmounted* once the hero is clear rather than paused -- the isolation viewer
owns a WebGL context of its own further down the page and two live contexts on
one document is how a laptop fan starts. Confirmed: zero <canvas> in the
server HTML.

prefers-reduced-motion is read before the observer is built, so the chunk is
never requested. That branch gets HeroPodStatic: the same three outlines from
the same tables, in the open pose, drawn with a depth copy behind each face.
Not a placeholder -- a reader who will never see the sequence still gets the
object the sequence exists to arrive at. It is also what a crawler gets.

Also: a scroll cue under the hero, one line and one chevron, which removes
itself the moment the reader scrolls. Its only job is getting them to "Three
things to ask it first", which already does the onboarding.

three.js has deprecated THREE.Clock and state.clock warned on every
construction, so the caret's blink runs off an accumulated delta instead.
The page's one set-piece. Four beats, dragged by the scrollbar: an empty chat
with the caret waiting, the install command typing itself into the composer,
the command sent and the output landing, then the chat coming apart and its
pieces converging into the mark.

It carries no heading and no eyebrow, because it is not a tenth section. It is
the animated form of what the hero and "Husk does two things" already say in
prose, and it sits between them.

## Nothing in it is invented

The command is MCP_COMMAND, the same string the hero's install block sets. The
transcript is the uname -sr / note.txt exchange out of TRANSCRIPT, pulled by
matching its text rather than by index, so adding a command to that array
cannot silently animate the wrong three lines.

The four tool names are captioned "the conversation now has", not "these
fired". The exchange is three shell calls; four tool names over it would imply
four tools ran. On a site whose argument is that it shows real output, that is
exactly the wrong place to round a claim up.

## Scroll, not time

A headline that types itself is lying about latency. A transcript being
dragged by the scrollbar is a diagram -- the reader sets the clock, it runs
backwards as readily as forwards, and it claims nothing about how long the
real thing takes.

## The content ships visible

Every line is real text in the server HTML: the command, the four tools with
their descriptions, the output. The states the timeline animates *from* live
behind [data-anim="on"], which script sets after mount, so a crawler, a reader
with JS off and a failed hydration all get a plain readable transcript rather
than a stack of opacity: 0. Nothing is display: none, so the text stays in the
accessibility tree through every beat. Confirmed: no data-anim in the server
HTML, all four tool names and the transcript present.

GSAP is imported inside the effect, behind an IntersectionObserver, not at
module scope -- a reader who never scrolls this far never downloads it. The
brief asked for next/dynamic with ssr: false here, which would have taken the
text out of the document along with the library; a lazy import gets the
payload off the critical path and keeps the content, which is what ssr: false
was being asked for.

prefers-reduced-motion returns before the observer is built, so that reader
gets the static transcript and no library at all.

## Two things measured rather than assumed

The pin's end was "bottom bottom" against a section given a fixed 300vh, with
pinSpacing off. That double-counts: the end resolved 900px before the timeline
finished and the pin released in the middle of beat four, and a reader with JS
off scrolled two viewports of empty section to get past it. ScrollTrigger owns
the length now -- end: "+=2400", pinSpacing on, no height in the CSS.

The mark's convergence anchor is measured, never a coordinate. ScrollTrigger
fires refresh on resize, on breakpoint changes and whenever the pin is
recalculated; the handler re-reads the stage box and re-points the transform
origin at its real centre each time. A hardcoded point is correct at exactly
one viewport width.

Verified by scrolling for real, both directions: typing, sent, tools, output,
mark, caption. window.scrollTo is silently refused by the preview harness --
PR #98 left that note and it saved an hour.
The hero and #mcp both hardcoded `claude mcp add`, while the copy under the
second one promised that "Cursor, Zed and anything else that talks MCP get the
same thing" without ever saying what those readers were meant to type. One
selector now serves both places, so they cannot drift.

Six panels: Claude Code, Codex CLI, Cursor, Zed, Antigravity, and a fallback
pointing at the README.

## Every line was read off the client's own docs today

Not from memory, not from another client that looked similar. The source URL
is on each entry and the date is in VERIFIED_ON. Three would have been wrong
by pattern-matching:

- Antigravity's config is `~/.gemini/config/mcp_config.json`. Several
  write-ups say `~/.gemini/antigravity/mcp_config.json`. Google's own MCP
  pages -- the IDE one and the CLI one -- both say `config`.
- Zed does not use `mcpServers`. Its key is `context_servers`, and its
  documented example carries an `env` object. Copying Cursor's shape here
  would have produced a file Zed ignores.
- Cursor's deeplink base64-encodes the *inner* server object, not the
  `{"mcpServers": {...}}` wrapper. The encoder reproduces the worked example
  in Cursor's docs byte for byte -- that is how it is known rather than
  assumed, and the generated link was decoded back in the browser to check.

Gemini CLI is deliberately absent: retired for free, Pro and Ultra users in
favour of Antigravity, so a panel for it would send most visitors at a dead
product.

Zed gets a filename rather than a path. Its docs reach for the command-palette
action because the real path differs across the three platforms; "settings.json"
plus the action in the note is correct everywhere, which a path would not be.

Cursor's and Antigravity's second paths mean opposite things -- global for
Cursor, per-workspace for Antigravity -- so the scope is a field on each
client rather than one sentence in the component. The first draft said
"~/.cursor/mcp.json to do it for one project only", which is backwards.

## One line of existing copy had to change

"There is no second step and no config file to edit" sat directly above a
config file to edit for three of the six clients. It now says which clients
that is true of and which get a block to paste. The heading loses "Claude
Code" for the same reason.

## The control

role="tablist" with real tabpanels, roving tabindex, arrow keys, Home and End,
and only the selected pill in the tab order -- a segmented control per section
4, not six buttons. Restrained on purpose: it is a functional control, not the
page's set piece, and it gets no entrance.

No vendor logos yet. Using another company's mark means following that
company's usage guidelines, and five of those are not something to guess at
inside an afternoon; the pills take a logo slot when the assets are sourced
deliberately. A text pill that is right beats a mark that is stylised or
stale.

Also in here: HeroObject read `pointer.current` during render and mirrored two
document-level values into state from effects. It takes the ref whole now, and
reduced-motion and theme both come from useSyncExternalStore with server
snapshots. eslint is clean across everything this branch touched.
The row of five buttons under the isolation viewer becomes a radial: the core
in the middle, a node per provider on the ring, a hairline spoke out to each,
and the active spoke lit. Same control, same state, same readout.

No numbers on the nodes. docker, podman, local, ssh and fly are five parallel
options, not five steps, and numbering a set invents an order the product does
not have -- `husk doctor` picks one, the reader is not walking through them.

The readout goes from two rows to four. The button labels used to be the only
place cost and "when it wins" appeared outside the table, and dropping them
with the buttons would have left the picture as the only source for two of the
five columns. Every value is the same string the table renders.

The table is untouched and still above it, visible, all five rows, no toggle
needed. It was never going to be the thing that moved.

## The drift the brief asked for, and the drift this has

The nodes do not orbit. They are click targets, and a target that moves is a
target you miss -- a worse trade than the motion buys. What drifts is one
faint dashed ring with nothing on it: same read, no hit area crossing the
pointer, and it stops outright under prefers-reduced-motion rather than being
collapsed to 1ms.

Under 40rem the ring is smaller than the labels it has to carry, so it stops
being a ring and becomes the wrapped list it always was underneath.

Hover, focus and click all select, so a reader tabbing through gets the same
readout a reader with a mouse gets rather than a hover-only affordance. The
selected node fills its dot as well as changing colour.

## Dropping the bloom pass

The hero's EffectComposer was compositing an opaque frame, which put a
hard-edged dark rectangle behind a canvas the whole hero depends on being
transparent. @react-three/postprocessing and postprocessing are uninstalled;
the glow is now one additive quad with a radial falloff -- a tight exponential
plus a wide one, which is the part of a bloom that reads.

Same look, no render target, and about 100K of JavaScript off a page whose
Total Blocking Time was the thing being fixed.

Also: removing the .frame wrapper left .core-stage drawing its own surface and
bottom rule, which is a rectangle in the middle of a circle of nodes and reads
as a hole. It is transparent inside the ring, and the readout carries the
border the wrapper used to own. The .core-controls rules went with the markup
they styled.
…icked

Three treatments, all quieter than the hero and the narrative on purpose.

## The husk.yaml frame tilts in

It lies back like a card on a table as it enters and rotates up to face the
reader. This section is the page's tangible payoff -- here is the actual file
you get -- and it is the one place outside the hero and the narrative that
gets a moment.

Not GSAP. One rotateX driven by one scroll position does not need a timeline:
a passive listener, rAF-coalesced, writing two custom properties. GSAP already
pays for itself on the narrative's four-beat pin; a second consumer here would
buy nothing.

The rect is re-measured every frame rather than cached at mount, so a resize,
a font swap or the section above changing height cannot leave the card stuck
at an angle. The listener attaches only while the frame is near the viewport.
Under prefers-reduced-motion it never attaches and [data-tilt] is never set,
so the frame renders flat and upright -- which is where the reveal ends anyway.

Measured: 22deg / 0.94 on entry, 0deg / 1.0 in place.

## The footer

It already existed, so this widens it rather than building a second one. The
wordmark gets a real size, and two link lists become four columns that
actually reach the docs -- nine sections were previously behind the nav's
single "Docs" link, which is one link for thirty-eight pages. The section
names and order are the docs site's own SECTIONS table.

No shimmer on the wordmark. A gradient sweeping across the mark forever is
motion with no information in it, on the one element that is purely the brand,
and brand/logo/USAGE.md bans decoration on the mark outright -- "no glow, no
gradient, no rotation, no bevel". Large was the part of that idea worth
having. The restrained hover is on the links instead: an underline that draws
from the left, done as a background-size transition so nothing about the box
changes and no neighbour reflows.

## Hover previews

One component, both sites. Hover or focus shows the destination's own share
image, its title and one line.

One image, two jobs. The card is the route's own opengraph-image, so what a
reader sees on hover is the same PNG the link unfurls to in a chat window.
That meant extracting the renderer and giving pricing, manifesto and every
documentation page one of their own -- thirty-seven prerendered on the docs
side, from the frontmatter each page already carries, so a preview cannot
drift from the page it previews.

Two things the implementation had to get right:

- The card is position: fixed, not absolute. The docs section grid is
  overflow: hidden -- that is what gives it its hairline dividers -- and an
  absolute card is clipped by it. Fixed also makes edge handling possible: the
  open handler measures the trigger, clamps the card inside the gutters and
  flips it below the link when there is no room above.
- Next will not allow the opengraph-image convention inside a catch-all
  segment ("Catch-all must be the last part of the URL"). The per-page docs
  cards are a route handler at /preview/[...slug] instead, out of the same
  renderer. Same pipeline, not a second one.

The card is aria-hidden and adds nothing to the accessibility tree: the link
text already says where it goes, and a screen reader does not need the
destination's title read on every focus. The image is fetched on first hover
and never on a touch device.

The "Read the source" card is generated here rather than pulled from
opengraph.githubassets.com. This site self-hosts its fonts and keeps analytics
on its own origin so that nothing is requested from anyone the reader did not
choose to talk to; a hover preview is not the place to break that for one PNG.

Verified all eighteen: four on the marketing site, nine section cards and five
cross-references on the docs. Every href correct, every image a distinct 200
image/png. Manifesto checked first, as asked: /manifesto, its own title and
lead, its own 1200x630 card.

## Also

The three section heads that did not have the entrance now do -- mcp,
husk.yaml and the free section -- so all six are one system rather than three
treated sections and three untouched ones. Same component, same timing.

drift-check --sites was asserting SITE_URL lived in content.ts and site.ts.
It moved to site-url.ts in the audit fixes, so those two assertions follow it,
plus one new one for the VERCEL_PROJECT_PRODUCTION_URL fallback. 32 ok.
Newsreader is 113K across its two faces and /manifesto is the only route that
sets it. Next's default prefetch was pulling both onto the homepage as soon as
the footer scrolled into view, and Chrome said so: "preloaded using link
preload but not used within a few seconds from the window's load event".

prefetch={false} on the two links that point there -- the header's and the
footer's. The preview card already shows the reader what is on the other side,
which is most of what the prefetch was buying, and /manifesto is a page most
visitors will not open.

Measured on the production build, in a clean browser context: 94K of font on
the homepage, three faces, no Newsreader, before and after scrolling the
footer into view.
The site now does several things brand/UI-PRINCIPLES.md and BRAND.md said it
must not. Leaving the rules contradicting the shipped page is the worst of the
available outcomes -- it makes the document something people stop reading -- so
this settles it either way.

Every ban below is kept. What changes is scope, and in each case the scope
moves to the reasoning the ban already gave rather than to whatever the new
code happens to need. Where the code was the thing that should move, it already
moved: there is no shimmer on the wordmark, the provider nodes do not orbit,
and the hero's core does not breathe.

## Section 3, motion

Two rows join the table: a scroll-scrubbed sequence, and an object tracking the
pointer. Neither has a duration, which is the point of both.

A new *Scroll as a clock* subsection sits above the ban list, because the
distinction belongs before the bans rather than as an exception buried inside
one. An animation on a timer asserts a duration. A sequence bound to scroll
asserts nothing: the reader sets the clock, it runs backwards, it stops where
they stop. Four conditions, and the third is the one that matters -- the
content ships real and readable, and the state it animates *from* is added by
script after mount.

- Ban 2 gains one named exception, the husk.yaml frame, licensed as a scrub
  rather than as an entrance. Reveals on code blocks stay banned: fading one in
  on a timer makes the reader wait to read; tilting one they are already
  dragging does not.
- Ban 3 becomes *scroll-driven* parallax. An object leaning six degrees toward
  a cursor has no layers, displaces no text, and takes an input the reader is
  deliberately moving -- that is a hover state with more axes. It does not
  exist on touch.
- Ban 4 becomes "anything that loops, unless the loop is the information", and
  then names all three that qualify: a spinner, a terminal caret, and the
  provider orbit's outer ring. The ring is decoration and is written down as
  the marginal case, with an instruction to cut it first if anything here ever
  fails its own test. The list is closed.
- Ban 6 becomes *time-driven* typewriter. The original argument -- a headline
  that types itself is lying about latency -- is unrefuted and is what the
  amendment turns on: a scrubbed string un-types when the reader scrolls back,
  which no latency ever does.

## Section 8, the slop patterns

- 8.1 gains a note that light coming off a lit object is not the gradient wash
  the pattern is about. The test is whether removing it changes what the reader
  can tell about the subject.
- 8.2 is amended to its own reason. "It depicts nothing" was always the whole
  of the case; it is now stated as the test, and both 3D objects on the site
  are held to it -- the hero is the mark's own three paths extruded, the
  isolation viewer is a reading of Availability.isolated. A rotating sphere
  still fails, on depicting nothing rather than on being 3D. The costs the
  pattern names are restated as owed, and they are paid.
- 8.8 defers to the closed list in section 3 item 4.

Section 9 gains a scrub check -- reverses, and readable with JS off, verified
against the served HTML rather than the hydrated page.

## BRAND.md

Type, Motion and Imagery. The colour system, the mark and all four pillars are
untouched.

The Type row names the three new faces and says why the serif exists at all.
A note under the table records where the faces actually live, because
tokens.css still declares the previous stack: the two sites re-point the three
tokens in their own globals.css, drift-check still requires the vendored copies
to match tokens.css byte for byte, and apps/console ships no font files so it
resolves to the fallback stacks either way and is unaffected.

Imagery keeps every prohibition and adds one exception, the mark itself, with a
pointer to the test in 8.2.

Version 1.1. drift-check 50 ok, --sites 32 ok.
@vercel

vercel Bot commented Sep 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
husk-dev Ready Ready Preview Sep 17, 2026 11:28pm UTC
husk-dev-docs Ready Ready Preview Sep 17, 2026 11:28pm UTC

@github-actions github-actions Bot added documentation Improvements or additions to documentation apps Touches apps/ (console, docs, web) build & ci Workflows, scripts, or root build config labels Sep 17, 2026
Two notes from review, both fair.

## The hero was the logo again

The mark is already in the header, in the footer and on the launcher; a fourth
copy in the hero was repetition, and a product called Husk should have
something husk-shaped in it.

It is a ribbed seed pod now: two shell halves with a real wall, split on a seam
that faces the reader, drawing apart to show the cavity and a faceted kernel
lighting inside. The flutes are a cosine on the outer radius, which is what
stops it reading as moulded plastic.

The wall is the whole trick, and it is what both earlier builds were missing.
A lathe has no thickness, so head-on it shows its concave inside as a flat
blob and hides the thing the opening exists to reveal -- that is why build one
failed. Extruding the mark's paths fixed the blob by being solid, and was the
logo. Each half here is a closed solid: outer surface, inner surface, and a cap
at each end of the arc joining them. The caps are the wall's edge, and they are
what you see when it opens.

Three things came out of looking at it:

- The halves yawed 38 degrees about the origin, which for an arc of nearly 200
  degrees swings the whole body across the centre line. The two ended up side
  by side with their backs to each other and the kernel behind both. Most of
  the parting is translation now.
- The kernel was at emissive 2.2 and clipped to white -- not teal, no facets,
  just a bright blob. 0.9, and the glow it throws is the halo's job.
- The floating terminal glyph is gone. It sat at the very edge of the frame and
  clipped at the hero's real column width, and beside a plainly organic pod a
  40px rectangle in the corner was clutter. The page says "computer" in the
  headline, in the install command and in the chat docked under it.

## The chat was parked in the middle of the page

It was a pinned full-viewport stage between the hero and "Husk does two
things", so the centre of the page was a set-piece the reader scrolled through
rather than read past. It is a docked widget now: launcher bottom-left, panel
above it, filling as the reader scrolls and folding back to the icon on the way
out. Clicking the launcher opens it at any time, and once clicked the scroll
stops closing it -- a widget should not argue with the person using it.

The scrub is the same scrub. It just plays in a 22rem panel, and the panel
follows its own latest line the way a chat does.

There is still exactly one copy of the content, in the page flow, visible on
arrival. `[data-anim="on"]` is what lifts that same element into the fixed
dock, so no-JS, failed hydration and reduced motion all get a readable
transcript in the body of the page -- full width, no internal scrollbar -- and
not a floating widget they cannot open. Verified in the served HTML: all four
tool names, the transcript, the closing line, no data-anim, no canvas.

## Two bugs found on the way

The opening sequence ran off accumulated deltas, which is only correct if every
frame is delivered. Under a throttled loop it stalled part-open and stayed
there. It runs off `performance.now()` now and reaches its end whatever the
frame rate -- which is what a reader who loads the page in a background tab and
switches to it actually gets.

`frameloop` was gated on a `running` prop. The canvas is a lazy chunk, and a
scene that mounted while the hero was briefly out of view landed in "demand"
and never advanced past frame one. It is "always", and the parent unmounting
the canvas off-screen is the real bound -- which is what the comments always
claimed.

## Rule docs

Section 3 item 4 permitted a blinking terminal caret. The caret was cut with
the glyph it sat in, nothing on either site blinks now, and a permission for
something nothing does is how a rule file drifts from the code. Withdrawn; the
list is two.

Reduced motion re-checked with it forced: static SVG hero, transcript readable
at full opacity, launcher hidden, and neither GSAP nor three.js fetched at all.
## The hero is the headline now

"Your AI chat gets a real computer of its own" is two things and a connection,
so the object is two things and a connection: a chat panel with a short
exchange in it, a machine below and behind it, and a cable carrying one pulse
into the machine's port, which lights when the pulse lands.

The sequence plays once -- panel arrives, its messages land one after another,
the machine slides up underneath, the cable draws, the pulse runs, the port
lights -- then it holds and tracks the pointer within six degrees.

## Three objects went in the bin to get here, and they failed differently

- A **lathed husk**: a flat blob head-on, because a surface with no wall has no
  inside to show.
- The **mark extruded**: solid and legible, and it was the logo -- which is
  already in the header, the footer and the chat launcher. A fourth copy
  depicts the brand, not the product.
- A **ribbed seed pod** with a real wall: a good object, correct on its own
  terms, and still a seed rather than a computer.

Section 8.2 asks whether an object depicts anything. All three passed that and
still failed, because "it depicts *us*" is not the same as depicting the claim.
The rule now says so, and names all three, since each is a mistake someone
could make again.

The lesson kept from them: build it from slabs with thickness. Both the panel
and the chassis are a rounded rectangle extruded with a small bevel, so every
edge catches the key light instead of going to a hard black line. Anything with
a front face and no depth reads as a sticker.

The chassis took a second pass. At 1.18 x 0.66 x 0.62 it was close enough to a
cube to read as a cardboard box; a machine is wide, low and shallow, and it
needed a recessed front plate before the face stopped looking like the side of
a solid. The panel got a title bar for the same reason -- without one it is a
dark rectangle with stripes on it rather than a window someone is talking in.

The bars are bars, not text. Text at this size is unreadable, and inventing a
legible transcript in the hero would be inventing a transcript.

## The chat dock moved to the right

Bottom-right, with the panel growing from that corner rather than the other.
Nothing else about it changed.

## Verified

Sequence completes and the port lights. Dock is `position: fixed` on the right
half at 1440. Reduced motion: static SVG hero drawn from the same tables, the
transcript in the page flow at full opacity, and neither GSAP nor three.js
fetched at all. drift-check 50 ok, --sites 32 ok.
Open the dock and scroll inside it: the command types into the composer, it
sends, the tools attach, the output lands, the launcher lights. Scroll back and
all of it un-happens. The page scroll no longer touches it.

## Why this is the right owner

The previous arrangement docked the panel but still scrubbed it from the
window's scroll position. That is wrong in a quiet way: the reader is reading
prose, and a widget in the corner is silently reacting to it. Neither the prose
nor the widget is in charge, and the reader has no way to tell that the corner
is a thing they can drive.

Containing the scrub inside the panel makes the ownership obvious, and it
deletes a whole class of bug with it -- the panel's scroll range *is* the
timeline, so a trigger's range and a timeline's duration can no longer
disagree. The last commit had exactly that bug and needed a slack tween to
paper over it.

ScrollTrigger is gone. Only gsap core is left, and the timeline is paused and
never played: `timeline.progress()` is fed the panel's scroll fraction, so it
is a lookup table rather than a clock.

## Two things about `position: sticky` that had to be learned the hard way

The stage is sticky inside the panel, and both of these silently produced a
panel whose content scrolled away instead of holding still:

- **A sticky element taller than its scrollport does not pin.** The stage was
  546px in a 479px panel, so scrubbing scrolled the transcript out of the box.
  The panel is sized from the stage's measured height now -- from the bounding
  rect and not `scrollHeight`, because `scrollHeight` is an integer, the real
  height is fractional, and rounding down left it one pixel short, which is
  enough for sticky to refuse.
- **A sticky child is constrained to its parent's content box, and padding is
  not content.** The scroll length was `padding-bottom` on the track, which
  gave the stage nowhere to stick through. It is an empty sibling element now.

Both are in the comments, because neither is visible in the code that has them.

Also: the panel only opens on click now, and opening resets it to the top --
a panel that reopens half-told has lost the reader's place without being asked
to. A one-word hint sits in the header until the panel is scrolled once, then
goes. The title shrinks with ellipsis rather than wrapping the header onto a
second row.

## Verified

Stage stays pinned at +1px from the panel top across the whole scrub, and the
beats fire in order: typing, sent, tools, output, attached, launcher lit.
Reduced motion: static SVG hero, dock static in the page flow, spacer
`display: none`, launcher hidden, transcript at full opacity, and neither GSAP
nor three.js fetched. Server HTML still carries the whole transcript with no
`data-anim`.
@Hotragn
Hotragn merged commit af0e427 into main Sep 18, 2026
13 checks passed
@Hotragn
Hotragn deleted the design/motion-visual-upgrade branch September 18, 2026 01:35

This branch was successfully deployed

2 active deployments
Preview – husk-dev-docs 6cb91153 Deployed Sep 17, 2026 by vercel[bot]
Preview – husk-dev 6cb91153 Deployed Sep 17, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

apps Touches apps/ (console, docs, web) build & ci Workflows, scripts, or root build config documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant