feat(motion): a loading state, and one spelling of the animated mark

There was no way to say "still working". ANIMATED_SVG folds in ONCE and stops
(`forwards`); MOTION_CSS is an intro flip plus a breathe loop. Neither is an
indeterminate state, so surfaces invented their own — the only spinner in the
fleet is ten hand-drawn raster frames in studio/branding.

getLoadingSVG() / LOADING_CSS / getLoadingMarkup() are the same origami fold the
mark already does, staggered across the five blocks and looping: each turns in
on its edge, holds while the others arrive, then turns out. Motion rather than
progress, which is what indeterminate should say. Built from MARK_BLOCKS and
MARK_SHADE, currentColor, prefers-reduced-motion honoured.

The one-shot mark hardcoded fill:#fff and .s{fill:#ddd}, so the animated mark
was invisible on a light page — the ground the house asks for. Both are
currentColor now, the slivers at reduced opacity rather than a second hex.

And svg/hanzo-mark-animated.svg was a hand-maintained SECOND spelling of that
animation — 5 paths and #fff against the constant's 7 and currentColor, one
package, two versions, already drifted. build.ts generates it from the constant
now, plus dist/logo-animated.svg with an export entry.

MARK_SHADE is exported: only the pre-baked MARK_PATHS string was reachable, so a
consumer wanting a different sliver ink had to retype the geometry.
This commit is contained in:
2026-08-24 19:34:34 -07:00
parent 933c07c751
commit 045cc9a6b6
7 changed files with 108 additions and 9 deletions
+25
View File
@@ -0,0 +1,25 @@
<svg role="img" aria-label="Hanzo" viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg">
<!-- Origami fold-in → hover: one 3-D turn → press: squash. Pure CSS, no JS. Canonical 7-path shaded H. -->
<style>.o{fill:currentColor;opacity:0;transform-box:fill-box;transform-origin:left center;animation:fold 0.42s cubic-bezier(.16,1,.3,1) forwards}
.s{fill:currentColor;opacity:.55}
@keyframes fold{from{opacity:0;transform:perspective(320px) rotateY(90deg)}to{opacity:1;transform:none}}
.o0{animation-delay:0.00s}.o1{animation-delay:0.06s}.o2{animation-delay:0.12s}.o3{animation-delay:0.18s}.o4{animation-delay:0.24s}
/* interaction — pure CSS, no JS: hover = one graceful turn (returns to rest), press = squash */
.hanzo-hov{transform-box:view-box;transform-origin:33.5px 33.5px}
.hanzo-prs{transform-box:view-box;transform-origin:33.5px 33.5px;transition:transform .22s cubic-bezier(.22,1,.36,1)}
svg:hover .hanzo-hov{animation:hanzoTurn 0.85s cubic-bezier(.45,0,.2,1)}
svg:active .hanzo-prs{transform:scale(.92)}
@keyframes hanzoTurn{0%{transform:perspective(300px) rotateY(0)}100%{transform:perspective(300px) rotateY(360deg)}}
@media (prefers-reduced-motion:reduce){.hanzo-hov,.hanzo-prs{animation:none!important;transform:none!important}.o{opacity:1;animation:none;transform:none}}
</style>
<g class="hanzo-hov"><g class="hanzo-prs">
<path class="o o0" d="M22.21 0H0V22.3184H22.21V0Z"/>
<path class="o o1" d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/>
<path class="o o1 s" d="M66.6753 22.3185L44.5098 20.0822V22.3185H66.6753Z"/>
<path class="o o2" d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/>
<path class="o o3" d="M22.21 67V44.6369H0V67H22.21Z"/>
<path class="o o3 s" d="M0 44.6369L22.21 46.8285V44.6369H0Z"/>
<path class="o o4" d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/>
</g></g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/logo",
"version": "1.0.22",
"version": "1.0.23",
"packageManager": "[email protected]",
"description": "Official Hanzo logo package with React components and utilities",
"type": "module",
@@ -41,7 +41,8 @@
"./logo-mono.svg": "./dist/logo-mono.svg",
"./logo-white.svg": "./dist/logo-white.svg",
"./svg/*": "./svg/*",
"./dist/*": "./dist/*"
"./dist/*": "./dist/*",
"./logo-animated.svg": "./dist/logo-animated.svg"
},
"scripts": {
"build": "pnpm clean && pnpm compile && pnpm build:cli && pnpm generate",
@@ -93,7 +94,7 @@
},
"brand": {
"name": "Hanzo",
"tagline": "Official brand marks — SVG, PNG, ICO, favicons & app icons.",
"tagline": "Official brand marks \u2014 SVG, PNG, ICO, favicons & app icons.",
"github": "hanzoai",
"domain": "hanzo.ai"
}
+9 -1
View File
@@ -2,4 +2,12 @@
// 7-path shaded H (5 body blocks + 2 shade slivers). Source of truth for the
// interaction layer: the tuner's GEN.hanzo() + the shared interaction layer.
// Origami fold-in → hover: one 3-D turn → press: squash. Pure CSS, no JS.
export const ANIMATED_SVG = "<svg role=\"img\" aria-label=\"Hanzo\" viewBox=\"0 0 67 67\" xmlns=\"http://www.w3.org/2000/svg\">\n<!-- Origami fold-in → hover: one 3-D turn → press: squash. Pure CSS, no JS. Canonical 7-path shaded H. -->\n\n<style>.o{fill:#fff;opacity:0;transform-box:fill-box;transform-origin:left center;animation:fold 0.42s cubic-bezier(.16,1,.3,1) forwards}\n.s{fill:#ddd}\n@keyframes fold{from{opacity:0;transform:perspective(320px) rotateY(90deg)}to{opacity:1;transform:none}}\n.o0{animation-delay:0.00s}.o1{animation-delay:0.06s}.o2{animation-delay:0.12s}.o3{animation-delay:0.18s}.o4{animation-delay:0.24s}\n/* interaction — pure CSS, no JS: hover = one graceful turn (returns to rest), press = squash */\n.hanzo-hov{transform-box:view-box;transform-origin:33.5px 33.5px}\n.hanzo-prs{transform-box:view-box;transform-origin:33.5px 33.5px;transition:transform .22s cubic-bezier(.22,1,.36,1)}\nsvg:hover .hanzo-hov{animation:hanzoTurn 0.85s cubic-bezier(.45,0,.2,1)}\nsvg:active .hanzo-prs{transform:scale(.92)}\n@keyframes hanzoTurn{0%{transform:perspective(300px) rotateY(0)}100%{transform:perspective(300px) rotateY(360deg)}}\n@media (prefers-reduced-motion:reduce){.hanzo-hov,.hanzo-prs{animation:none!important;transform:none!important}.o{opacity:1;animation:none;transform:none}}\n</style>\n<g class=\"hanzo-hov\"><g class=\"hanzo-prs\">\n<path class=\"o o0\" d=\"M22.21 0H0V22.3184H22.21V0Z\"/>\n<path class=\"o o1\" d=\"M66.7198 0H44.5098V22.3184H66.7198V0Z\"/>\n<path class=\"o o1 s\" d=\"M66.6753 22.3185L44.5098 20.0822V22.3185H66.6753Z\"/>\n<path class=\"o o2\" d=\"M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z\"/>\n<path class=\"o o3\" d=\"M22.21 67V44.6369H0V67H22.21Z\"/>\n<path class=\"o o3 s\" d=\"M0 44.6369L22.21 46.8285V44.6369H0Z\"/>\n<path class=\"o o4\" d=\"M66.7198 67V44.6369H44.5098V67H66.7198Z\"/>\n</g></g>\n</svg>";
//
// currentColor, not #fff. This hardcoded white, so the animated mark was
// invisible on a light page — which is the ground the house asks for. The shade
// slivers ride the same ink at reduced opacity rather than a second hex, so one
// `color` themes the whole thing.
//
// This is the ONE-SHOT mark: it folds in and stops. For an indeterminate
// "still working" state use getLoadingSVG() / LOADING_CSS from ./motion.
export const ANIMATED_SVG = "<svg role=\"img\" aria-label=\"Hanzo\" viewBox=\"0 0 67 67\" xmlns=\"http://www.w3.org/2000/svg\">\n<!-- Origami fold-in → hover: one 3-D turn → press: squash. Pure CSS, no JS. Canonical 7-path shaded H. -->\n\n<style>.o{fill:currentColor;opacity:0;transform-box:fill-box;transform-origin:left center;animation:fold 0.42s cubic-bezier(.16,1,.3,1) forwards}\n.s{fill:currentColor;opacity:.55}\n@keyframes fold{from{opacity:0;transform:perspective(320px) rotateY(90deg)}to{opacity:1;transform:none}}\n.o0{animation-delay:0.00s}.o1{animation-delay:0.06s}.o2{animation-delay:0.12s}.o3{animation-delay:0.18s}.o4{animation-delay:0.24s}\n/* interaction — pure CSS, no JS: hover = one graceful turn (returns to rest), press = squash */\n.hanzo-hov{transform-box:view-box;transform-origin:33.5px 33.5px}\n.hanzo-prs{transform-box:view-box;transform-origin:33.5px 33.5px;transition:transform .22s cubic-bezier(.22,1,.36,1)}\nsvg:hover .hanzo-hov{animation:hanzoTurn 0.85s cubic-bezier(.45,0,.2,1)}\nsvg:active .hanzo-prs{transform:scale(.92)}\n@keyframes hanzoTurn{0%{transform:perspective(300px) rotateY(0)}100%{transform:perspective(300px) rotateY(360deg)}}\n@media (prefers-reduced-motion:reduce){.hanzo-hov,.hanzo-prs{animation:none!important;transform:none!important}.o{opacity:1;animation:none;transform:none}}\n</style>\n<g class=\"hanzo-hov\"><g class=\"hanzo-prs\">\n<path class=\"o o0\" d=\"M22.21 0H0V22.3184H22.21V0Z\"/>\n<path class=\"o o1\" d=\"M66.7198 0H44.5098V22.3184H66.7198V0Z\"/>\n<path class=\"o o1 s\" d=\"M66.6753 22.3185L44.5098 20.0822V22.3185H66.6753Z\"/>\n<path class=\"o o2\" d=\"M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z\"/>\n<path class=\"o o3\" d=\"M22.21 67V44.6369H0V67H22.21Z\"/>\n<path class=\"o o3 s\" d=\"M0 44.6369L22.21 46.8285V44.6369H0Z\"/>\n<path class=\"o o4\" d=\"M66.7198 67V44.6369H44.5098V67H66.7198Z\"/>\n</g></g>\n</svg>";
+7
View File
@@ -13,6 +13,7 @@ import * as path from 'path';
import { fileURLToPath } from 'url';
import sharp from 'sharp';
import { getColorSVG, getMonoSVG, getMenuBarSVG, getFaviconSVG, getFaviconRasterSVG, getWhiteSVG, getLightSVG } from './logos.js';
import { ANIMATED_SVG } from './animated.js';
// Namespace view of the same module — lets the build pick up OPTIONAL marks
// (e.g. a wordmark) that only some brands define, without a hard import.
import * as marks from './logos.js';
@@ -227,6 +228,12 @@ async function buildAll(): Promise<void> {
fs.writeFileSync('dist/logo-mono.svg', monoSVG);
fs.writeFileSync('dist/logo-white.svg', whiteSVG);
fs.writeFileSync('dist/logo-light.svg', getLightSVG());
// The standalone animated mark is GENERATED from the constant. It was a
// hand-maintained second spelling — 5 paths and #fff against the constant's
// 7 and currentColor — i.e. two versions of one animation in one package,
// already drifted.
fs.writeFileSync('svg/hanzo-mark-animated.svg', ANIMATED_SVG);
fs.writeFileSync('dist/logo-animated.svg', ANIMATED_SVG);
fs.writeFileSync('dist/logo-menubar.svg', menuBarSVG);
fs.writeFileSync('dist/favicon.svg', faviconSVG);
console.log('✓ Generated 6 SVG sources\n');
+5 -2
View File
@@ -29,8 +29,11 @@ export const MARK_BLOCKS = [
'M66.7198 67V44.6369H44.5098V67H66.7198Z',
];
/** The two accent slivers that give the H its shaded depth. */
const MARK_SHADE = [
/** The two accent slivers that give the H its shaded depth.
* Exported alongside MARK_BLOCKS: a consumer that wants a different sliver ink
* had to retype the paths, because only the pre-baked MARK_PATHS string was
* reachable. */
export const MARK_SHADE = [
'M0 44.6369L22.21 46.8285V44.6369H0Z',
'M66.6753 22.3185L44.5098 20.0822V22.3185H66.6753Z',
];
+53 -1
View File
@@ -1,4 +1,4 @@
import { getMenuBarSVG } from './logos.js';
import { getMenuBarSVG, MARK_BLOCKS, MARK_SHADE } from './logos.js';
/**
* Brand motion — the shared shell animation for the Hanzo mark, pure CSS:
@@ -40,3 +40,55 @@ export function getMotionHTML(wordmark = 'Hanzo'): string {
export function getMotionMarkup(wordmark = 'Hanzo'): string {
return `<style>${MOTION_CSS}</style>${getMotionHTML(wordmark)}`;
}
/**
* The loading state — the origami fold, made indeterminate.
*
* The package had brand motion and no way to say "still working": ANIMATED_SVG
* folds in ONCE and stops (`forwards`), and MOTION_CSS is an intro flip plus a
* breathe loop. Neither is a spinner, so surfaces invented their own — the only
* one in the fleet is ten hand-drawn raster frames.
*
* This is the same fold the mark already does, staggered across the five blocks
* and looping: each block turns in on its edge, holds while the others arrive,
* then turns out. It reads as motion rather than progress, which is what an
* indeterminate state should say.
*
* `currentColor`, so it is visible on any ground — the one-shot animation
* hardcodes `fill:#fff` and is invisible on the white the house asks for.
*/
export const LOADING_CSS = `.hanzo-loading{display:inline-block;line-height:0;color:inherit;width:1.25em;height:1.25em}
.hanzo-loading svg{display:block;width:100%;height:100%}
.hanzo-loading .hz-fold{fill:currentColor;transform-box:fill-box;transform-origin:left center;animation:hanzo-loading-fold 1.8s cubic-bezier(.16,1,.3,1) infinite}
.hanzo-loading .hz-fold-1{animation-delay:.09s}
.hanzo-loading .hz-fold-2{animation-delay:.18s}
.hanzo-loading .hz-fold-3{animation-delay:.27s}
.hanzo-loading .hz-fold-4{animation-delay:.36s}
@keyframes hanzo-loading-fold{0%{opacity:0;transform:perspective(320px) rotateY(90deg)}20%,60%{opacity:1;transform:none}86%,100%{opacity:0;transform:perspective(320px) rotateY(-90deg)}}
@media (prefers-reduced-motion:reduce){.hanzo-loading .hz-fold{animation:none;opacity:1;transform:none}}`;
/**
* The looping mark on its own. Pair with LOADING_CSS, or use
* getLoadingMarkup() for both at once.
*/
export function getLoadingSVG(): string {
const shadeAt: Record<number, string> = { 0: MARK_SHADE[0], 3: MARK_SHADE[1] };
const parts = MARK_BLOCKS.map((d, i) => {
const block = `<path class="hz-fold hz-fold-${i}" d="${d}"/>`;
const sliver = shadeAt[i]
? `<path class="hz-fold hz-fold-${i}" opacity=".55" d="${shadeAt[i]}"/>`
: '';
return block + sliver;
});
return (
`<svg role="img" aria-label="Loading" viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg">` +
parts.join('') +
`</svg>`
);
}
/** The loading mark and its CSS as one self-contained fragment. */
export function getLoadingMarkup(): string {
return `<style>${LOADING_CSS}</style><span class="hanzo-loading">${getLoadingSVG()}</span>`;
}
+5 -2
View File
@@ -1,7 +1,8 @@
<svg role="img" aria-label="Hanzo" viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg">
<!-- Origami fold-in → hover: one 3-D turn → press: squash. Pure CSS, no JS. Generated from the @hanzo logo tuner — edit there, not here. -->
<!-- Origami fold-in → hover: one 3-D turn → press: squash. Pure CSS, no JS. Canonical 7-path shaded H. -->
<style>.o{fill:#fff;opacity:0;transform-box:fill-box;transform-origin:left center;animation:fold 0.42s cubic-bezier(.16,1,.3,1) forwards}
<style>.o{fill:currentColor;opacity:0;transform-box:fill-box;transform-origin:left center;animation:fold 0.42s cubic-bezier(.16,1,.3,1) forwards}
.s{fill:currentColor;opacity:.55}
@keyframes fold{from{opacity:0;transform:perspective(320px) rotateY(90deg)}to{opacity:1;transform:none}}
.o0{animation-delay:0.00s}.o1{animation-delay:0.06s}.o2{animation-delay:0.12s}.o3{animation-delay:0.18s}.o4{animation-delay:0.24s}
/* interaction — pure CSS, no JS: hover = one graceful turn (returns to rest), press = squash */
@@ -15,8 +16,10 @@ svg:active .hanzo-prs{transform:scale(.92)}
<g class="hanzo-hov"><g class="hanzo-prs">
<path class="o o0" d="M22.21 0H0V22.3184H22.21V0Z"/>
<path class="o o1" d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/>
<path class="o o1 s" d="M66.6753 22.3185L44.5098 20.0822V22.3185H66.6753Z"/>
<path class="o o2" d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/>
<path class="o o3" d="M22.21 67V44.6369H0V67H22.21Z"/>
<path class="o o3 s" d="M0 44.6369L22.21 46.8285V44.6369H0Z"/>
<path class="o o4" d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/>
</g></g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB