202 Commits
Author SHA1 Message Date
jwinterm 4face640db Merge branch 'feat/brand-page-20260906' 2026-09-06 09:33:16 -04:00
jwinterm 2961a11b2a feat(web): /brand, the identity system as a page
The brand book now exists, and this is where it is read. Every value comes from
`styles/brand-guide.json`, a generated projection of the pack in such-graphics;
nothing on the page is typed by hand, and the pack's gate refuses if this copy
drifts from what the generator emits.

It shows the mark, the four-colour palette with its measured contrast per theme,
what the palette measures under each kind of colour vision with the binding pair
named, the type and motion vocabulary, the three facets with the cascade's depth
against its total length, and roughly eleven thousand characters of the reasoning
behind all of it, lifted from the pack's own decision log.

The last section is what the guide does NOT say, starting with clearspace, which
no pack key declares. A guide that fills a gap with a plausible number is worse
than one that names the gap.

It lives here rather than on a fifth site: `web_site_deploy` unconditionally runs
a build and provisions no vhost, DNS or TLS, so a standalone host is a much
larger reviewed change than a route. And it is on our own infrastructure, which
is the point of it existing at all.
2026-09-06 09:33:16 -04:00
jwinterm 3db477d128 Merge branch 'chore/regenerate-pulse-animations-20260906' 2026-09-06 09:21:29 -04:00
jwinterm 9e94ffcc8f chore(brand): regenerate the pulse animations from the pack
The generator changed twice in such-graphics: the pulse glow filter gained an
explicit region, and the field is now emitted one animated element per branch so
a daughter track can be delayed by its own arrival.

These four files are generated projections, and the pack gate refused until they
were regenerated. Nothing here was hand-edited; run
`such-graphics` and copy, or let the consumer lane tell you which files moved.

Visible change on this site: none. Both circuit and games are single-route
facets, so their paths, dasharrays and node delays are byte-identical and the
only difference is an added `b0` class plus the wider filter region. The
branching behaviour these commits unlock belongs to the cascade facet, which the
site does not ship yet.
2026-09-06 09:21:29 -04:00
jwinterm cdd6dbc078 Merge branch 'copy/education-broader-20260906' 2026-09-06 01:15:10 -04:00
jwinterm c713f93675 copy(services): education leads broad, nuclear is the specialty inside it
The education pillar opened with "PhD-led radiation-science teaching" in its
lead, its card, its meta title and its meta description, and the services page
and the FAQ described the whole pillar the same way. That is the deepest thing we
do there and it is also the narrowest, so it was reading as the only thing: a
school looking for edutainment, or a team looking for training, had to get past
a national-lab credential to find out we were talking to them.

The pillar now leads with education and edutainment (games that teach science,
math, vocabulary, nutrition and cyber-safety through play, plus teaching,
training and speaking) and names radiation science as the deep specialty inside
it, with GEANT4, shielding and detector physics kept verbatim so the terms that
already rank still appear.

The "Nuclear consulting & training" proof item is unchanged. It is a real
nuclear service and should say so; it was the framing of the whole pillar that
was too narrow, not that entry.
2026-09-06 01:15:10 -04:00
jwinterm 9ddc040021 Merge branch 'feat/hash-bags-ios-live-20260906' 2026-09-06 01:10:36 -04:00
jwinterm 7400fa30e4 feat(web): consume the brand pack instead of retyping it
The site had been copying brand facts out of a generator that could emit them.
The four glyph hexes were hand-typed into globals.css in both themes, and the
splash duration sat in a TypeScript constant under a comment asking the next
person to keep two repositories in agreement by reading pack.json. That comment
was doing the job a build should do.

styles/brand.css is now the generated projection, committed here because this
repository cannot run the generator, and imported ahead of Tailwind. The four
gradients read var(--sg-glyph-*); there are zero hardcoded glyph hexes left in
globals.css. hero-splash reads --sg-dur-splash rather than declaring 1337, and
the literal that remains is a fallback for a missing stylesheet, announced in
the console, not a second copy of the value.

The file is held to the pack by lane 3 of check_consumer_projections in
such-graphics, default closed behind SG_CONSUMER_ROOT_SUCH_SOFTWARE_SITE. Do not
hand-edit it: regenerate with `such-graphics project-brand` and copy the result.

Verified in the built stylesheet rather than assumed: 84 --sg-* definitions land,
--sg-dur-splash:1337ms among them, and .hero-title compiles to the var() form.
2026-09-06 01:02:57 -04:00
jwinterm 2aead35842 feat(web): Hash Bags is on the App Store
The iOS build shipped, and four places on the site still said it was coming.
/apps carried `{ platform: "apple", href: null }`, so the store row rendered a
dead slot next to a live Google Play button. The product page said the iOS build
was in review, described the app as Android-only in its prose, and declared
`operatingSystem: "Android"` in its SoftwareApplication JSON-LD, which is the
line search engines and app-discovery surfaces actually read. The products index
said "Android now; iOS and desktop coming."

All four now name the App Store listing, and desktop stays honestly described as
still coming.
2026-09-06 00:54:38 -04:00
jwinterm 302e2d62d6 feat(web): point the hero at the services, and say the offer in the hero
The primary button sent every visitor to /products, a sixteen-item grid mixing
services for sale with consumer games we publish. A buyer looking for commerce
work landed among mobile games. It now goes to /services, which is where the four
pillars and the terms actually are.

The subhead also now states the offer, which the site made five times deeper in
and never on the front page. Worded as a qualified option rather than a headline:
revenue share is something web services CAN be provided on, not the studio's
model for games, crypto tooling or the education work.

The H1 is deliberately unchanged. "Precision Engineering for Everyone" names no
buyer and no category, but replacing it is a positioning decision rather than an
engineering one, and it stays open. The two events landed in the previous commit
mean the next attempt at it can be measured rather than argued.
2026-09-06 00:13:09 -04:00
jwinterm 7208095b06 feat(web): count the two moments on the contact form that matter
There is not a single custom event in this codebase, which is why every argument
about the site's copy has been an argument about taste. Two events, a visitor
beginning the contact form and a visitor sending it, turn the hero rewrite that
follows this into a before-and-after instead of an opinion.

`contact-started` fires once per mounted form, on the first real edit rather than
on focus: someone tabbing through on the way elsewhere is not a lead.
`contact-submitted` fires on success. Both carry only which form it was, inquiry
or support.

`track()` does no consent checking of its own on purpose. UmamiAnalytics already
declines to load the script under Global Privacy Control and the script honours
Do Not Track, so `window.umami` is simply absent for a visitor who opted out and
every call is a no-op. A second gate here could disagree with the first. It also
never throws: an analytics call is not worth losing a form submission over.

/privacy now says what we count, in the same commit that starts counting it.
2026-09-05 23:43:57 -04:00
jwinterm abc968fe6f feat(web): a gate for the repository, starting with the tags in the built HTML
The site had no CI of any kind: no .github, no .gitea, no workflow. The only
automation touching production is the redeploy timer, and it runs no checks. So
`pnpm run check` now exists as the single command, lint then build then the
metadata assertions, and it is the thing a CI lane will eventually call.

The first assertion reads the emitted HTML, because that is the only place the
defect it catches is visible. Next composes metadata at build time and merges it
shallowly, so a page declaring its own `openGraph` silently replaces the parent's
image. No diff, no type error and no lint rule shows that; only the output does.
Reintroducing the bug on /support was confirmed to fail this check, and the
refusal names the route, the missing tag, and what breaks without it.

It refuses rather than passing vacuously when there is no build output, or when
the output holds no HTML. A metadata checker that reports success over zero pages
is worse than none, because it is quoted.

Four tags per page, each with its reason: og:image, twitter:image, canonical,
description. 51 of 51 today.
2026-09-05 23:42:40 -04:00
jwinterm d1d56ef744 fix(web): fail closed on Turnstile, keep the social card, derive the sitemap
Five things that were each one line of wrong and are cheap to hold shut.

Turnstile failed OPEN. `lib/turnstile.ts` returned true whenever
TURNSTILE_SECRET_KEY was unset, and logged the visitor's token while doing it.
Lose that variable in a deploy and the contact form quietly stops checking
anything, the only trace a warning nobody reads. Missing configuration is now a
refusal; the dev bypass survives as an explicit TURNSTILE_DEV_BYPASS=1 that a
production build ignores, rather than as a side effect of an empty variable. The
caller now distinguishes "you failed the check" from "we cannot run the check",
because telling a real visitor to refresh, when the server has no key, sends them
round a loop that cannot succeed.

The social card was being dropped by the pages that cared enough to write their
own. Next merges metadata shallowly, so a page exporting `openGraph: { title,
description }` replaces the parent's whole block, image included. Seventeen pages
did that; five had an opengraph-image.tsx to fall back on and twelve shared as a
bare grey rectangle. `pageMetadata()` takes the parts that differ and fills in
the parts that must not go missing. Built output goes from 23/51 pages carrying
og:image and 12/51 carrying twitter:image to 51/51 on both. Canonical was already
51/51 and stays there.

The sitemap was a hand-kept list and had drifted nine routes behind the site,
among them /products/{vegan-iq,suchoice,bauhaus-echo,such-moon-launch}/privacy,
which Apple and Google fetch during store review. It now walks the route tree, so
a page is advertised because it exists. Dynamic segments still come from their
data (blog posts, service pillars), and /contact is excluded WITH its reason: it
redirects, and a sitemap should name the destination rather than the door.
Result: 37 static routes, up from 28, nothing removed.

`vegan-iq-data-pipeline.mdx` was `draft: true`, which kept the most substantial
post on the site out of the index, the sitemap and generateStaticParams.

And /contact was unreachable on a first visit. It redirects to /#contact, but the
splash owns the first screen and entering it pins scroll to 0, so every inbound
contact link played the intro and dropped the visitor at the top of the homepage
with the hash discarded. A fragment that names something real on the page now
skips the splash and is honoured once, on arrival.

Verified: pnpm build green, lint clean (8 pre-existing no-img-element warnings,
none from this change), og/twitter/canonical counted in the built HTML.
2026-09-05 23:40:46 -04:00
jwinterm e906e7884d Add Evaluetron terms and privacy policy
Evaluetron is a generation service reached on the web and through Discord
and Telegram, and the umbrella policies do not describe it. They mention
no AI prompts, no generated content, and no third-party model providers,
which is the main thing this product does with user data: a prompt sent
to a resold model leaves for that provider.

Written against what the service actually does. Generated files are
deleted after seven days; prompts and job records are currently kept
indefinitely because nothing deletes them, and the policy says so rather
than implying a schedule that does not exist.

The terms state what credits are, since the whole product runs on them:
prepaid service credits, not money, and not redeemable, transferable, or
refundable. Also that a blocked account forfeits them, and that resold
models apply their own content policies independently of ours.

Both umbrella documents already defer to products publishing their own
policies, and now list Evaluetron alongside Hash Bags, Smirk and the
rest.
2026-08-30 10:45:20 -04:00
jwinterm 3d7732c88b blog: studio voice on the PageSpeed post, and an update for the new splash
Title takes "Our" instead of "My", and the body follows it: fifteen instances of
first-person singular are now plural. The second "Our" in the title became "the"
to avoid the doubling.

Swept the same judgment language out of it. "I like it", "the obvious thing",
"The obvious suspect", "First gotcha worth knowing", "What I would actually tell
you", "Why I care about the boring version". A report states what happened and
lets the reader rank it. Kept the honest method reporting ("we got it wrong
twice"), which is evidence rather than editorial.

Swept the other posts too and deliberately changed nothing in them.
such-moon-launch-release uses "I" for a real authorship claim, "the very first
game I ever wrote", which becomes false as "we". The two hits in
vegan-iq-data-pipeline are an idiom and a quoted hypothetical. Site copy under
src/ came back clean.

NEW UPDATE. The post described a splash that no longer exists, so it now carries
the replacement and what measuring it turned up.

The pillar signature replaced the Cherenkov splash. Making it advance on its own
surfaced a defect that had been shipping: CLS 0 to 1.0, caused not by the new
animation but by the splash having always been a min-h-[100svh] block in document
flow that only became fixed on the way out. CLS excuses shifts within 500ms of
input, so clickers never contributed it and the headline 100 was accurate only
for them. Reduced-motion and returning-session visitors, who never click, scored
74 with CLS 1.0 against production.

Numbers are measured, not recalled: mobile 97 (LCP 1.2s, CLS 0.013, TBT 20ms),
desktop 98 (LCP 1.0s, CLS 0.005, TBT 0ms), run against production this week. The
reduced-motion path is 98 where it was 74.
2026-08-21 05:36:43 -04:00
jwinterm 037304b1ad fix(blog): render tables, drop typography's backticks, correct the transport claim
TABLES. next-mdx-remote had no remark plugins, so GFM tables rendered as literal
pipe characters in a paragraph. Four published posts were affected, not just the
new one: bauhaus-echo-launch, flutter-3d-physics-dice, vegan-iq-data-pipeline
and smirk-v0-3-0. remark-gfm is now wired into MDXRemote's mdxOptions.

BACKTICKS. @tailwindcss/typography wraps every inline <code> in literal backtick
characters via code::before / code::after. The served HTML was already clean;
the marks were injected by CSS. Monospace already marks code as code, so the
backticks were a redundant second marker that reads as a typo in running prose.
Suppressed across .prose.

TRANSPORT. The previous version's "transports" section covered only Grin slate
channels. Tor is a separate mechanism and was undocumented: the wallet accepts a
.onion backend origin over plain http, alongside private ranges and .local,
because onion services carry their own transport authentication and no CA issues
for them. The onion pattern matches the label immediately before .onion, so an
onion-lookalike public host is refused. On the backend, the admin plane binds to
loopback and TOR_ADMIN_ONION adds exactly one onion host to its Host allowlist
as a DNS-rebinding guard.

There is no bundled Tor daemon and no I2P support anywhere in either repo. The
post now says so rather than leaving it implied.

COPY. Removed the editorial voice throughout: no more "the part worth
explaining", "the instructive one", "worth dwelling on". A technical report
states what a thing does and lets the reader decide what matters. Headings are
now descriptive rather than argumentative.
2026-08-21 05:29:35 -04:00
jwinterm 55400cf6d9 blog: rewrite the Smirk post as a shipped release report, with diagrams
Rewritten as a technical report on what v0.3.0 does, written as released rather
than pending. Cut the "working notes" framing and the narrative voice.

Three inline SVG diagrams carry the parts that are awkward in prose:

  - Federation: two wallets on two independent backends, each delegating to its
    own chain nodes, with the Nostr relay layer joining the WALLETS rather than
    routing through a backend. The first draft drew that line from a wallet to a
    backend, which quietly asserted the opposite of the point.
  - Signing tiers: signEvent routed by kind into money / session-grantable /
    default, with the actual kind numbers.
  - Slate transports: the three Grin transports with their discovery keys, and
    which are federated versus same-instance.

They are inline SVG rather than images: text stays selectable and translatable,
they scale without artefacts, and structure drawn in currentColor inherits the
prose colour so both themes are correct by construction. Verified rendered in
light and dark rather than assumed. Accent colours are mid-tones chosen to read
on both grounds. Every figure carries role="img" and an aria-label describing
what it shows.

Sourced from the repos: docs/grin.md for the transport table and discovery
rules, packages/dapp-api/src/nostr-tiers.ts for the kinds, CHANGELOG.md and
smirk-backend-core's README for the rest.
2026-08-20 15:41:53 -04:00
jwinterm 91513bcb2a chore(webshops): say shop, not tenant, in the last place it slipped through
The page rewrite dropped the operator's word for a customer everywhere except
one blurb in the shared surfaces gallery, which the page renders.
2026-08-20 14:47:10 -04:00
jwinterm 26a2707cf6 blog: build notes on Smirk v0.3.0, Nostr identity, and self-hosting
Written as working notes from the release window rather than an announcement,
and framed as forthcoming: v0.3.0 is cut and in store review, not on anyone's
machine, so the post says so instead of implying availability.

Built around the design decision rather than the feature list. The interesting
part of the Nostr work is not that there is a NIP-07 provider, it is that a
signature is a bearer credential and so "remember this site" is a security
decision. Hence the three event-kind tiers, and specifically why NIP-98 (27235)
had to move to money-tier: the backend mints a full session from one, so leaving
it session-grantable let a connected site collect an auth token for the user's
own backend under a permission granted for social posts. Enforcement sits in the
wallet handler rather than the approval dialog, so the rule survives a hostile
UI.

Then the federation half, which is what makes smirk-backend-core worth writing
about: the wallet no longer assumes api.smirk.cash, which is only a real claim
if there is something to point it at.

Sourced from the repos, not from a summary: CHANGELOG.md, the tier table in
packages/dapp-api/src/nostr-tiers.ts, and smirk-backend-core's README. Both
linked repos verified public before publishing.

981 words, about a third shorter than the post it follows.
2026-08-20 14:45:00 -04:00
jwinterm 8587509eed rewrite(webshops): write the page for the client, not for us
The page led with our architecture. "Multi-tenant commerce platform", "from one
engine", "per-tenant theming is the whole point": all true, none of it what
someone deciding whether to hire us is trying to find out. It also called the
reader a "tenant" throughout, which is an operator's word for a customer.

It was long and it repeated itself. Theming was explained four times over (hero,
a dedicated section, the demos intro, the surfaces intro), and revenue share,
crypto, tax, and data ownership each appeared in a feature card AND an FAQ. The
feature grid ran to eleven cards, several of them vendor filler: "Flexible
Architecture", "Performance First".

Now: what it is, what you can sell, how you get paid, what it costs, who runs
it. Six feature cards instead of eleven. The standalone theming section is gone,
folded into the hero and the demos. Eight questions instead of ten, each one
something a buyer would actually ask, and the answers are shorter than the
questions deserve rather than longer.

The technical detail survives, moved to one block at the bottom under a heading
that says who it is for. That is where Medusa, Next.js, the payment rails, and
the hosting story live now.

Two things fixed while in here. The demos are labelled as demo storefronts,
because they are ours and not client work, and the caption no longer lets that
blur. And the CTA pointed at /contact, a 307 redirect hop that was fixed
elsewhere months ago and missed here.
2026-08-20 14:40:42 -04:00
jwinterm 0324c8fab9 fix(anim): drop <object> for <img>, and say cryptocurrency
THE GREY BOX. The decorative animations were embedded with <object>, which
creates a nested browsing context that paints its own canvas. On the dark pages
that canvas showed as a light grey square behind the mark. The SVG's own baked
tile had been hiding most of it, so only the rounded corners leaked, which is
why it read as "grey corners" rather than "grey box": removing the tile exposed
the whole thing and made the real cause obvious.

<img> has a transparent canvas and still runs CSS animations inside an SVG, as
the splash morph and the Hash Bags icon already demonstrate. Swapped on all
three pages that used the pattern, not just the one that was reported: /apps,
/products and /blog all had it. Verified on the rendered dark page, where the
pixels outside the mark, inside the old tile, and the page background are now
the same colour.

The marks themselves are now the tileless variants from such-graphics f457c37,
so nothing bakes a background colour that has to match ours.

COPY. "crypto payment tools" -> "cryptocurrency tools", and the supporting line
now names both ways of working with us rather than only the products.
2026-08-19 15:08:19 -04:00
jwinterm 82aa93baad Merge fix/splash-scroll-lock 2026-08-18 16:25:07 -04:00
jwinterm a354ed5b7b fix(splash): stop the handover re-locking scroll for the rest of the visit
First visit of a session ended with the page unscrollable. A hard refresh
appeared to fix it, which is what made it look like a caching oddity rather
than a bug: the session flag then skips the splash and neither lock runs.

Two things were managing html.style.overflow and they fought.

  1. The splash's own lock sets overflow:hidden while the cover is up, saving
     the previous value ("").
  2. enterFromButton then captured the previous value for the transition lock.
     By then it was already "hidden", from step 1.
  3. `entered` flips, the splash lock cleans up and restores "". Page scrolls.
  4. Up to 3s later the transition's release() restores its captured value,
     which is "hidden". Page is locked, and nothing ever clears it.

The release is now unconditional. By the time it runs the splash is gone and
the page must scroll, so restoring a captured value is wrong by construction:
there is no state worth preserving, only a lock to drop.

Verified with a real wheel event 5s after load, past both the 1337ms gate and
the 3s release cap where the re-lock used to land: overflow is empty and the
page scrolls.
2026-08-18 16:25:07 -04:00
jwinterm c11afe6c3a Merge fix/hash-bags-slant 2026-08-18 10:39:34 -04:00
jwinterm d3b2fa718a fix(icons): the Hash Bags # leans, as it does everywhere else
I shipped it upright. It is a typographic monospace hash and it leans, which is
how it renders across hash.boats (hero and every feature card) and how
hash.boats/splash.svg draws it by hand: "verticals lean right".

Where the upright one came from, because the wallet repo disagrees with itself.
assets/images/hash_wallet_app_icon_source.svg rebuilt the glyph as paths to
remove a font dependency and flattened the lean while doing it, and
store/play-icon-512.png is upright too: its verticals measure x=206 and x=306
on every row, no lean at all. I took those as canonical and explicitly rejected
the slanted splash.svg as wrong. That was backwards. The brand's own site is
the authority on the mark; an app-store export is downstream of it.

Both icons are now rebuilt from splash.svg's glyph, mapped onto a full-bleed
1024 canvas preserving its proportions exactly (glyph spans 57.5% of the tile,
stroke is 8.75%). The static PNG is rendered from the same geometry as the
animated SVG rather than being a separate export, so the two cannot drift: the
animation ends on precisely the still icon. Measured after: the left bar's
centre runs 225 -> 204 -> 182 down the tile, where the old one sat at 206 on
every row.

Still full-bleed, not splash.svg's inset rounded box, because the site rounds
icons with a 22% CSS radius and a built-in transparent margin would show card
background at the corners.
2026-08-18 10:39:21 -04:00
jwinterm 3d12167a19 Merge chore/apps-copy-and-order 2026-08-18 10:23:47 -04:00
jwinterm dccf26e603 chore(apps): drop "AI-powered", sort the list reverse alphabetically
Suchoice's tagline was "AI-powered decision making", which named the
implementation instead of the thing you get. It now reads "Six ways to decide,
from a coin flip to 3D dice", which is what the product page already says and
is concrete enough to tell you whether you want it.

Order is now derived, not hand-maintained: sortedApps sorts on name descending
at render. Hand-ordering the array works right up until someone adds an app to
the end and the page is quietly wrong, which is the same class of problem as a
hand-maintained timing constant.

Reverse alphabetical gives Vegan IQ, Suchoice, Such Moon Launch, Hash Bags,
Bloomword, Bauhaus Echo. Note Suchoice sorts above Such Moon Launch: the
comparison is locale-aware, so the space in "Such Moon" does not simply win on
byte order the way it would with a raw string compare.
2026-08-18 10:23:47 -04:00
jwinterm 9d2b7a1c53 Merge feat/animated-hash-bags-icon 2026-08-18 08:33:19 -04:00
jwinterm b1590c3a2b feat(apps): the Hash Bags icon draws itself on
An animated icon has to still BE the icon, so the geometry is copied verbatim
from the app's own icon source (hash-wallet
assets/images/hash_wallet_app_icon_source.svg): full-bleed #1a5c38, four white
lines at stroke-width 80 with square caps, horizontals at y=412/612, verticals
at x=412/612, all spanning 212..812. It draws on in 850ms and settles into a
frame that is pixel-for-pixel the static PNG.

Not hash.boats/splash.svg, which was the obvious candidate and is wrong twice:
its # is slanted where the shipped icon is upright, and it insets its green box
inside a transparent margin. The site rounds icons with a 22% CSS radius, so
that margin would have shown card background at the corners, which is the exact
artifact we just finished removing from the icon set.

CSS rather than SMIL, for the reason the brand pack already documents:
prefers-reduced-motion is a media query and cannot reach inside a SMIL
<animate>. Under reduced motion this is just the finished icon.

Draws once and holds, rather than looping. Five icons looping forever on a
directory page is a distraction, and an app icon's resting state should be an
app icon.

`animatedIcon` is optional per app and `unoptimized` is set only when it is
used, because the image optimizer would rasterise the SVG and throw the
animation away.
2026-08-18 08:33:01 -04:00
jwinterm b0bd6a46ef Merge feat/burst-and-hashbags: handover burst, Hash Bags launch 2026-08-17 14:20:20 -04:00
jwinterm 0b1c4000c3 feat(hash-bags): add Hash Bags to the apps page, and give it a product page
Live on Google Play (com.suchsoftware.hashwallet); the iOS build is in review
and desktop follows, so both render through StoreButtons' existing null-href
"Coming Soon" state rather than claiming availability we do not have.

Content is taken from the project's own README and store-listing draft rather
than invented: open-source, non-custodial, Wownero-first, twelve chains, and
Ledger/Trezor/BitBox support. The "why it exists" section is the real reason,
which is the interesting part of this product: Cake Wallet ended Wownero
support in early 2026 and this fork keeps it first-class.

Icon is the shipped play-icon-512, already square and opaque, so it takes the
site's rounded-[22%] cleanly and cannot show a transparent corner wedge.

Also linked out to hash.boats, listed on /products, and added to the sitemap.
2026-08-17 14:20:11 -04:00
jwinterm 835c5c8084 feat(splash): a colour burst as the mark hands over to the page
Subtle and half-transparent, per the brief. Four blobs, one per monogram cell,
each in that letter's own colour, thrown outward from where the letter was: the
mark comes apart into its own palette rather than flashing something unrelated.
The peak opacity is 0.42 light / 0.5 dark and the gradients never reach full
alpha, so it stays a wash rather than a curtain.

Only transform and opacity animate. Both are compositor properties, so this
does not repaint per frame. That is deliberate: animating a filter is what
pegged a core on the matrix animation, because a filter change re-renders the
whole filter graph every frame. Soft edges come from a static radial-gradient
that is painted once and only ever transformed.

The overlay could no longer fade as one piece. Ground and mark fade in 260ms
while the burst runs 420ms plus a 25ms per-blob stagger, so fading the
container would have taken the burst down with it halfway through. They fade
independently now and the shell unmounts at 540ms instead of 300ms, which is
the burst's real length rather than the cross-fade's.

Hidden entirely under prefers-reduced-motion, though those visitors never
reach the splash anyway.
2026-08-17 14:19:56 -04:00
jwinterm e119d1c641 Merge feat/pillar-splash: pillar-signature splash, pre-paint flash fix 2026-08-17 12:11:42 -04:00
jwinterm 887b59629d feat(splash): the pillar signature, and no more flash on refresh
Two separate problems, one of them a real bug.

THE FLASH. The splash is server-rendered, so on a refresh the browser painted
it and only then did hydration read sessionStorage, decide this visitor had
already entered, and remove it. That one painted frame is the flash, and React
cannot prevent it: even useLayoutEffect runs after the server HTML is painted.

So the decision moved before paint. A tiny synchronous script in <head> reads
the session flag and prefers-reduced-motion and stamps the root element; a
plain CSS rule hides the overlay in the frame it would otherwise appear in.
The overlay is `fixed`, so hiding it shifts no layout. Verified with a
Playwright run: after reload the root is already stamped at readyState
"interactive", the overlay computes to display:none, and the splash is not
visible in any of twelve sampled frames.

THE SPLASH ITSELF. It is now only the pillar signature: four dots become the
four pillar symbols -- commerce, crypto, games, education -- become the word
SUCH, then the page takes over. One generated asset (morph_splash*.svg) whose
CSS timeline is scaled to land exactly on 1337ms, from such-graphics 628fcc1.
SPLASH_MS here and motion.splash.ms there are the same number by construction:
a gate that closes early cuts the last letter mid-draw.

Everything else on the splash is gone -- tagline, supporting line, the four
corner nav links, the chevron, the water-refraction filter and its pre-warm.
The corner links were unreachable inside a gate this short anyway, and the
header carries them. splash-logo-data.ts goes with them, now orphaned.

Perf, measured on a local production build: mobile 97, desktop 98, unchanged
from what production scores today. CLS stays ~0. LCP moves 0.9s -> 1.2s on
mobile, because the mark is now a 12KB request where the old logo was an
inlined data URI. Both theme variants are fetched (3.7KB each gzipped) since
`dark:hidden` hides but does not prevent the download, so half of that is
waste. Fixable by inlining as data URIs the way splash-logo-data.ts did, at
the cost of ~30KB in the bundle; not done here because the score did not move.
2026-08-17 12:11:36 -04:00
jwinterm fae995d653 Merge feat/new-app-icons: new Bauhaus/Suchoice icons, uniform 22% corner radius 2026-08-17 11:44:46 -04:00
jwinterm 787753aef1 feat(icons): new Bauhaus Echo and Suchoice icons, and one radius everywhere
New store icons from bauhaus-echo/assets/store/app_icon_512.png and
suchoice/assets/icon_1024.png, both resampled to 512 and both square and fully
opaque. Bauhaus drops 320KB -> 72KB, Suchoice 259KB -> 142KB.

The corner artifact had a measurable cause. Five icons bake their own rounded
corners as transparency (suchoice 20.5%, smirk-wallet 20.1%, vegan-iq 19.3%,
mushroom-capital 17.0%, such-moon-launch 15.2% of their width); the rest are
square. CSS then rounded them again, at a radius that differed per page:
21.4% on /apps at 56px, 25% at 64px, and 20% at 80px.

20% is BELOW suchoice's 20.5% baked corner, so on /products/suchoice the CSS
clip fell inside the transparent corner and a wedge of card background showed
through at each corner. That is the "different coloured stuff next to the
rounded corners": not a rendering bug, an arithmetic one.

Every square app icon now uses one percentage radius, rounded-[22%]. It is
above every baked radius in the set, so no transparent corner can ever be
exposed, and being a percentage it stays identical at 56, 64 and 80px instead
of drifting with the pixel size. The two new icons are square, so they cannot
produce the artifact at any radius.

The non-square occupy-wallets banner keeps a fixed radius: a percentage on a
non-square box goes elliptical.
2026-08-17 11:44:40 -04:00
jwinterm 6bfbc88375 Merge fix/games-arc-corners: games arc clears the frame corners 2026-08-17 11:35:27 -04:00
jwinterm 848deaf371 fix(brand): regenerate the games pulse so its arc clears the frame corners
Copied through from such-graphics db616f8. The launch arc's feet ran to y=178,
inside the frame's corner arcs (centred (26,174) and (174,174), r=12), so on
/apps the leg, its cap and its node dot all sat on the corner and the mark read
as broken. The feet now stop at the glyph baseline, y=164.

Generated, not hand-edited: the pack is canonical and a regen would have
overwritten a local fix.
2026-08-17 11:35:16 -04:00
jwinterm b9f9cc042a Merge feat/splash-auto-advance: self-entering splash, overlay fixes live CLS 1.0 2026-08-17 09:13:34 -04:00
jwinterm 1a66e2217a feat(splash): enter the site on its own, and stop the intro costing 26 points
The splash now runs its water-ripple enter 1.5s after paint instead of waiting
to be clicked. A click, scroll, or arrow key still fast-forwards it, and the
once-per-session flag and reduced-motion skip are unchanged.

Auto-advancing on its own would have cost the Lighthouse score badly: mobile
100 -> 73, desktop 97 -> 73. The cause was not LCP, which never moved. It was
CLS going 0 -> 1.0, and the reason is worth writing down.

The splash was `relative min-h-[100svh]`, a full viewport of height IN document
flow, and only became `fixed` on the way out. Dismissing it therefore pulled a
whole screen out of the flow and shoved the hero up: a layout shift of almost
exactly 1.0. Clicking hid that from CLS, because shifts within 500ms of user
input are excused. Nothing else did.

Which means this was already shipping to real people. Visitors with
prefers-reduced-motion, and anyone returning within a session, never click, so
they took the shift in full: production measured 74 on mobile with CLS 1.0.
The click-gate was masking a live bug, not preventing one.

The splash is now a fixed overlay from its first paint, so it is never in flow
and dismissing it cannot reflow anything. Measured on a local production build:

                          before -> after
  mobile, auto-advance     73 -> 98   (CLS 1.0   -> 0.005)
  mobile, reduced-motion   74 -> 98   (CLS 1.0   -> 0.005)
  desktop, auto-advance    73 -> 98   (CLS 0.702 -> 0.011)

Mobile gives up 2 points against today's click-gated 100, entirely in Speed
Index (1.9s -> 4.4s), which is the unavoidable cost of holding a cover at all.
Desktop ends up ahead of where it started. The hold is one constant if you want
a different beat.

The scroll-past-to-dismiss IntersectionObserver is gone: an overlay never
leaves the viewport, so it could not fire. Scroll intent still enters, via the
wheel/touch/key handlers already in the splash, and the shell now locks page
scroll while the cover is up.
2026-08-17 09:13:29 -04:00
jwinterm b110da076d Merge chore/eslint-flat-config: ESLint CLI + flat config, off deprecated next lint 2026-08-17 06:06:19 -04:00
jwinterm e1ea0ce18c chore(lint): migrate off next lint to the ESLint CLI and flat config
`next lint` is deprecated and disappears in Next 16. apps/web now runs
`eslint .` against eslint.config.mjs on ESLint 9, and the shared preset moves
to packages/config/eslint-preset.mjs. eslint-config-next 15 still ships only
an eslintrc-style config, so FlatCompat translates it; that keeps
next/core-web-vitals as the single source of rules.

The migration was done by hand rather than via @next/codemod, because the
codemod does not know about the shared @repo/config preset.

Two things worth knowing.

ESLint 9 only walks file extensions that some config names in `files`. The
first working version of this config linted three loose .js files, skipped
every .tsx in src/, and still exited 0 -- a green run that checked nothing.
The preset now names the extensions explicitly. Coverage went from 3 files to
82, and a deliberately broken canary component confirms rules still fire.

`@eslint/eslintrc` also needed a second scoped override: it does a default
import of minimatch and declares ^3.1.2, which the global ^9.0.7 audit
override broke. Same shape as the ajv fix in the previous commit.
2026-08-17 06:06:14 -04:00
jwinterm dd009e8493 Merge fix/blog-punctuation-and-lint: working linter, blog and README em dashes removed 2026-08-17 05:53:21 -04:00
jwinterm 1dc0c5f06a fix(lint): make the linter run again, and finish the em dash sweep
The linter had two faults stacked on top of each other.

First, the global `ajv: ^8.17.1` override starved @eslint/eslintrc, which
needs ajv 6 and passes it `missingRefs`; ajv 8 rejects that option, so ESLint
died before loading a single rule. Scoped overrides give eslint and
@eslint/eslintrc ajv 6.12.6 and leave the audit override in place everywhere
else.

Second, underneath that, `extends: ["@repo/config/eslint-preset.js"]` could
never resolve: ESLint's shareable-config naming convention rewrites it to
`@repo/eslint-config-config/...`. require.resolve hands ESLint a real path.

With the linter running it reported 35 react/no-unescaped-entities errors
across 7 files. Those are escaped rather than silenced, using ESLint's own
coordinates so only flagged text nodes were touched. Lint now exits 0. Two
@next/next/no-img-element warnings remain on the splash logos, which are
deliberate: theme-paired and fetchPriority="high".

Em dashes are also gone from the six blog posts and the README (105
occurrences), same rules as the site copy: colons for definition labels,
commas for appositives, semicolons for independent clauses.
2026-08-17 05:53:16 -04:00
jwinterm d20ae5ea1a Merge feat/hero-voice-punctuation: tagline title, slower gradient drift, em dashes removed 2026-08-17 05:34:29 -04:00
jwinterm 0848fcabde feat(site): the tagline takes the title, and the em dashes go
The hero h1 is now "Precision Engineering for Everyone". The concrete offer
moves into the supporting line, which keeps the search terms the h1 gives up
and no longer restates the splash's opening sentence.

The title's gradient drifts instead of sweeping: a 300% ramp over 18s with
symmetric easing, so there is no hard turnaround at either end of the travel.

Em dashes are gone from every tsx/ts file (87 lines, 104 occurrences), per
AGENTS.md rule 9. Label lists and titles take a colon; appositives take
commas; independent clauses take a semicolon or a period. A few sentences
were restructured where no single mark read well.

Metadata keeps "Crypto" deliberately: it is a real service line and a real
search term, and it is no longer in the visible headline.
2026-08-17 04:18:18 -04:00
jwinterm 127081bcac Merge feat/brand-refresh-v3: pack-generated brand, one voice, brand gradients 2026-08-16 00:04:56 -04:00
jwinterm 261cb04667 feat(site): one voice, brand gradients, a lighter splash gate, denser portfolio
- Voice unified on the title-tag register: the homepage h1 now says what we
  sell (Custom E-commerce, Apps & Crypto Tools); 'Precision engineering for
  everyone' is demoted to the supporting line. The structured-data and
  manifest descriptions now match the OG description instead of carrying
  their own registers.
- The off-palette cyan/blue gradients are gone: .hero-title and the new
  .brand-gradient-text use the four-colour system (light palette on light,
  dark palette on dark, per the pack). Splash cover text, services and
  portfolio headings, the hero aura, and button shadows all follow.
- The splash now dismisses once 40% scrolled past instead of requiring the
  whole screen to leave the viewport (it was already once-per-session and
  reduced-motion-skipping).
- Portfolio: heading 'Portfolio', subtitle 'Products & projects', meta title
  to match; grid grows to three columns on xl in a max-w-6xl container.
- anim3_matrix slimmed: single-layer glow, unfiltered crackle and orbs, and
  the border cycles stroke colour instead of animating filter:hue-rotate --
  the filter animation forced a full filter re-render per frame and pegged a
  core on the page hosting it.
2026-08-16 00:04:28 -04:00
jwinterm c73b9f1c04 feat(brand): regenerate every mark and animation from the such-graphics pack
All branding and animation assets now come from packs/such-software/pack.json
(such-graphics feat/brand-full-blast) instead of hand-maintained files:

- anim1_pulse / anim3_matrix / anim_type_v4 (both themes): regenerated with
  the corrected U bowl, measured pulse timing (the hand-authored node delays
  ran ~17% fast), and CSS animation with real prefers-reduced-motion support.
- clean marks, lockups (clean_*_w_text), favicons (svg + 192px png), and the
  OG banner: regenerated; the OG card is the pack's 1200x630 projection.
- splash-such: rebuilt from the pack -- the old one carried the divergent U,
  an off-palette cyan frame, and the pre-correction radius. Now theme-aware:
  two inlined data URIs (hero-splash shows the light palette on light, the
  dark palette on dark) since the pack's dark hues are tuned for near-black.
- new assets available: games_pulse (the launch arch with the rocket, wired
  into /apps), morph, ambient_{atom,mesh,leaf}, favicon_dots, favicon_live,
  icon-192/512 + maskable-512 (wired into manifest.json), /favicon.ico.
- animation embeds switched <img> -> <object>: Chrome freezes CSS animations
  inside SVG-as-img (the old files animated because they were SMIL); an
  object hosts a real SVG document where CSS animation and the
  reduced-motion query both work. Verified live in Chrome.
2026-08-15 23:18:44 -04:00
jw b6fb6b743d fix(moonlaunch): disclose the telemetry and analytics the game actually sends
The policy described leaderboards and cloud save as the only things that leave
the device, and stated outright: "This data is not transmitted to our servers
unless you use the leaderboard or cloud save features." That is not true.

- Gameplay telemetry POSTs to api.such.software on every platform, automatically
  rather than opt-in, tagged with the anonymous device ID, display name and
  platform. It carries level starts and completions with times and stars, how a
  run ended (cause, hazard, speed, fuel, progress through the level), rewarded-ad
  and share actions, purchase attempts, and error messages. None of it was
  disclosed.
- Google Firebase (Analytics and Crashlytics) ships in the mobile builds and
  receives the same device ID as a user property alongside app version, device
  model, OS version and language. It was not mentioned anywhere, including in the
  third-party recipients table.
- Cloud save uploads automatically on every save, not only on an explicit action,
  and its payload now also carries store purchase receipts (the ledger that stops
  a purchase being credited twice).
- The page claimed Google AdSense serves ads on the web build. It does not:
  web/custom_shell.html records that the AdSense placeholders were removed and
  web monetization is handled in-game. Removed the claim and the AdSense row from
  the recipients table.

Left as-is: the statement that iOS serves non-personalized ads and does not
access the IDFA. No code path presents the ATT prompt, so the identifier stays
zeroed and that claim holds.
2026-08-15 11:17:50 -04:00