Motion and visual upgrade across both sites, and the audit fixes under it - #109
Merged
Merged
Conversation
…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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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`.
This branch was successfully deployed
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.
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 onapps/webresolved tohttp://localhost:3000in production — homepage,
/pricing,/manifesto, the 404. One?? "http://localhost:3000"reached wheneverNEXT_PUBLIC_SITE_URLis unset,and setting it in the dashboard fixes that exactly once.
The chain is now
NEXT_PUBLIC_SITE_URL, thenVERCEL_PROJECT_PRODUCTION_URL,then
VERCEL_URL, then localhost. Verified by building withVERCEL_PROJECT_PRODUCTION_URL=husk.dev: canonical,og:url, the robots hostand every sitemap entry came out
https://husk.dev.You still need to set
NEXT_PUBLIC_SITE_URLin both Vercel projects. Thefallback 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.apphost.The docs site had no canonical and no
og:tag on any page. #103 stripped atrailing slash and #87 set
metadataBase— which is what those tags resolveagainst, not a substitute for declaring them.
<html lang>was not changed: it is present on both layouts and onglobal-error, so the earlier report was reading pre-hydration HTML.What is in it
/manifestoonly.next/font/local, instanced and subset before commit.husk.yamlMeasured
target-sizeMarketing site: accessibility 100, SEO 100, best practices 96. The one failing
audit on both sites is
/_vercel/insights/script.js404ing on localhost, whichis 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-motionCSSrules 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:
~/.gemini/config/mcp_config.json. A number of write-upssay
~/.gemini/antigravity/...; Google's own IDE and CLI pages both sayconfig.mcpServers. Its key iscontext_servers, and itsdocumented example carries an
envobject. Copying Cursor's shape here wouldhave produced a file Zed ignores.
{"mcpServers": {...}}wrapper. The encoder reproduces the worked example inCursor'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:
inside to show.
already in the header, the footer and the chat launcher.
still a seed rather than a computer.
§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:
logo/USAGE.mdbans decoration on themark outright. Large was the part of the idea worth keeping.
ring drifts instead.
And one dependency went the other way:
@react-three/postprocessingwascompositing 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
gsapcore is left, driven bytimeline.progress().Rule changes
brand/UI-PRINCIPLES.md§3 and §8 andBRAND.md§8 are amended. Every ban iskept and narrowed to the reasoning it already gave, in the shape #98's revert
used. Two worth flagging for review:
spinner, and the provider orbit's ring. The ring is written down as
decorative and as the first thing to cut.
is unchanged and is what the amendment turns on.
BRAND.mdgoes to 1.1. The colour system, the mark and all four pillars areuntouched.
drift-check50 ok,--sites32 ok.Still open
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.
husk run's approval promptprints. The one place the docs quote that prompt shows one character in a
fallback face. Quoted output stays quoted.
file to edit" sat directly above a config file to edit for three of the six
clients.
requestAnimationFramehard, so every sequence here is confirmed correct andconfirmed to complete, but not judged for pace. The two knobs are
OPEN_SECONDSinlib/hero-scene.tsand.nr-spacer { height }inglobals.css.