fix: unify landing glyph animation with pinned site fonts
Reference site / build (push) Canceled after 0s

This commit is contained in:
2026-10-11 01:08:38 +09:00
parent 39e1565cb9
commit ef5ea5e528
28 changed files with 1046 additions and 198 deletions
+42
View File
@@ -9,13 +9,16 @@
"version": "0.1.0",
"dependencies": {
"@astrojs/markdown-remark": "7.3.2",
"animejs": "4.5.0",
"astro": "7.3.8"
},
"devDependencies": {
"@astrojs/check": "0.9.10",
"@playwright/test": "1.64.0",
"@types/node": "24.19.1",
"@types/opentype.js": "1.3.10",
"cheerio": "1.2.0",
"opentype.js": "2.0.0",
"typescript": "~5.9.3"
},
"engines": {
@@ -1833,6 +1836,13 @@
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/opentype.js": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@types/opentype.js/-/opentype.js-1.3.10.tgz",
"integrity": "sha512-F67EFyk6j02okHz5JCgata3ZRAcZi9GLnzmkHw/rzJq3OCc8/ZVdoKrxMTYjcQP6IYHGBz2cav1cpzkOkPiPCQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -2025,6 +2035,28 @@
"am-i-vibing": "dist/cli.mjs"
}
},
"node_modules/animejs": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/animejs/-/animejs-4.5.0.tgz",
"integrity": "sha512-NQimYX+lz8WaXonGS9zVVoviCIAjONeJayxacUditaivYLqyXgGjFKjuyl0aUUhFKm5MriX9ty1TGovNzoJQWA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/juliangarnier"
},
"peerDependencies": {
"@types/three": ">=0.150.0",
"three": ">=0.150.0"
},
"peerDependenciesMeta": {
"@types/three": {
"optional": true
},
"three": {
"optional": true
}
}
},
"node_modules/ansi-regex": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
@@ -4986,6 +5018,16 @@
"regex-recursion": "^6.0.2"
}
},
"node_modules/opentype.js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-2.0.0.tgz",
"integrity": "sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==",
"dev": true,
"license": "MIT",
"bin": {
"ot": "bin/ot"
}
},
"node_modules/p-limit": {
"version": "7.3.3",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.3.tgz",
+3
View File
@@ -18,13 +18,16 @@
},
"dependencies": {
"@astrojs/markdown-remark": "7.3.2",
"animejs": "4.5.0",
"astro": "7.3.8"
},
"devDependencies": {
"@astrojs/check": "0.9.10",
"@playwright/test": "1.64.0",
"@types/node": "24.19.1",
"@types/opentype.js": "1.3.10",
"cheerio": "1.2.0",
"opentype.js": "2.0.0",
"typescript": "~5.9.3"
}
}
+4 -3
View File
@@ -2,10 +2,11 @@ import './plugins/environment.mjs';
import { defineConfig } from '@playwright/test';
const base = process.env.SITE_BASE?.replace(/\/$/, '') || '';
const port = process.env.SITE_TEST_PORT || '4322';
export default defineConfig({
testDir: './tests/browser',
use: {
baseURL: `http://127.0.0.1:4322${base}/`,
baseURL: `http://127.0.0.1:${port}${base}/`,
javaScriptEnabled: false,
launchOptions: { executablePath: process.env.CHROME_PATH },
},
@@ -14,8 +15,8 @@ export default defineConfig({
{ name: 'mobile', use: { viewport: { width: 390, height: 844 } } },
],
webServer: {
command: 'npm run preview -- --host 127.0.0.1 --port 4322 --ignore-lock',
url: `http://127.0.0.1:4322${base}/`,
command: `npm run preview -- --host 127.0.0.1 --port ${port} --ignore-lock`,
url: `http://127.0.0.1:${port}${base}/`,
reuseExistingServer: false,
},
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
+94
View File
@@ -0,0 +1,94 @@
Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
Copyright 2025 Deborah Khodanovich (RCS citation glyphs)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+112
View File
@@ -0,0 +1,112 @@
Copyright (c) 2015-2026, Renzhi Li (aka. Belleve Invis, belleve@typeof.net)
Build configuration (c) 2026, Yuri Turov
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
--------------------------
SIL Open Font License v1.1
====================================================
Preamble
----------
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
Definitions
-------------
`"Font Software"` refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
`"Reserved Font Name"` refers to any names specified as such after the
copyright statement(s).
`"Original Version"` refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
`"Modified Version"` refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
`"Author"` refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
Permission & Conditions
------------------------
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1. Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2. Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3. No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5. The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
Termination
-----------
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+104
View File
@@ -0,0 +1,104 @@
# Self-hosted fonts
These are unmodified binaries from official upstream sources. Only the faces
used by the site are retained: Lyth Mono regular/bold, EB Garamond regular/bold
and their italics, plus each family's regular TTF for build-time outline
extraction with `opentype.js`. Browsers use WOFF2, not the TTFs. No Nerd, Term,
Square, Round, variable, light, or medium faces are included.
## Lyth Mono
- Repository: <https://github.com/why-trv/LythMono>
- Selected non-prerelease release: **v0.10.0**, published 2026-02-06.
- Pinned tag commit: `71d1cf4f325663705ab41ee1908427ea435b2e5d`.
- Pinned Iosevka source submodule: `a90e82cf6f37cc9b82e47151024c86cfe0693874`.
- Official release archive:
<https://github.com/why-trv/LythMono/releases/download/v0.10.0/LythMono.zip>
- Archive SHA-256, checked against the GitHub release asset digest:
`06b8c05671572e8cba61bf494404d3b973df348c607cd33f3153a374247d6607`.
- Extracted only `LythMono/WOFF2/LythMono-Regular.woff2`,
`LythMono/WOFF2/LythMono-Bold.woff2`, and
`LythMono/TTF/LythMono-Regular.ttf` (hinted versions). The archive is not
retained.
- License: `LythMono-LICENSE.md`, copied from `LICENSE.md` at the pinned commit;
SIL Open Font License 1.1, including upstream copyright notices.
- Internal family: **Lyth Mono**. Regular full name: **Lyth Mono**;
PostScript name: **Lyth-Mono**. Bold PostScript name: **Lyth-Mono-Bold**.
Regular TTF version: `Version 0.10.0; ttfautohint (v1.8.4)`.
Regular TTF metrics (font units): units/em 1000; hhea ascender 925, descender
-195, line gap 50; OS/2 typo ascender 925, descender -245, line gap 0;
Windows ascent/descent 925/245; cap height 720; x-height 540. ASCII `0`, `W`,
`i`, `p`, and the four box glyphs below all advance **604** units (0.604em;
8.456px at 14px). This is not a 0.6em font. Glyph count: 12174.
| Character | Unicode | Glyph ID | Ink bounds `(xMin, yMin, xMax, yMax)` |
| --- | --- | --- | --- |
| │ | U+2502 | 7535 | (264, -245, 340, 925) |
| ├ | U+251C | 7561 | (264, -245, 604, 925) |
| └ | U+2514 | 7553 | (264, 306, 604, 925) |
| ─ | U+2500 | 7533 | (0, 306, 604, 374) |
These are real, nonempty outlines verified with `opentype.js`, not fallback
characters. The horizontal stroke's center is y=340, and vertical strokes
reach the font's 925/-245 line extents. Explicit line-height and vertical
alignment matter when joining animated strokes to text.
## EB Garamond
- Official source: <https://github.com/octaviopardo/EBGaramond12>
- Pinned commit: `106a4a6d377987459ae5e68673a4570f13b957fb`.
- This is the upstream revision recorded by Google Fonts in
`ofl/ebgaramond/METADATA.pb` at distribution commit
`f8c1d3d6cc75e30d77130bdcbfbff27e3b6233fe`.
- No published GitHub release is available at the source repository. Files
were downloaded directly from that pinned source commit, not a third-party
font CDN: `https://raw.githubusercontent.com/octaviopardo/EBGaramond12/106a4a6d377987459ae5e68673a4570f13b957fb/`.
- Source paths: `fonts/webfonts/EBGaramond-{Regular,Bold,Italic,BoldItalic}.woff2`
and `fonts/ttf/EBGaramond-Regular.ttf`.
- License: `EBGaramond-OFL.txt`, copied from `OFL.txt` at the pinned commit;
SIL Open Font License 1.1, including upstream copyright notices.
- Internal family: **EB Garamond**. Regular full name: **EB Garamond Regular**;
PostScript name: **EBGaramond-Regular**. Other included PostScript names:
**EBGaramond-Bold**, **EBGaramond-Italic**, **EBGaramond-BoldItalic**.
- The static regular TTF reports `Version 1.002; ttfautohint
(v1.8.4.16-eb64)`. Google Fonts' newer variable version is not included;
the static faces above are deliberately used as shipped by upstream.
Regular TTF metrics (font units): units/em 1000; hhea and OS/2 typo ascender
1007, descender -298, line gap 0; Windows ascent/descent 1047/390; cap height
650; x-height 400. Advances: `0` 480, `W` 916, `i` 245, `p` 519. Glyph count:
3247. It does not contain the four box-drawing characters above.
## Japanese / CJK fallback
Neither regular TTF maps any codepoints in Hiragana (U+3040–309F), Katakana
(U+30A0–30FF), CJK Unified Ideographs (U+4E00–9FFF), or Extension A
(U+3400–4DBF). `あ`, `ア`, `漢`, `日`, and `本` all map to glyph 0 in both.
Do not use their `.notdef` outlines for Japanese glyph extraction.
The proportional CSS stack uses installed `Noto Serif JP`, `Hiragino Mincho
ProN`, `Yu Mincho` / `YuMincho`, then generic `serif`. The mono stack retains
system monospace choices and installed `Noto Sans Mono CJK JP`. These are
fallback names only: no additional CJK font is downloaded or requested from
an external provider. Japanese metrics depend on the user's installed font.
## Build-time outlines
`src/lib/landing-glyphs.mjs` uses `opentype.js@2.0.0` during Astro rendering.
It reads `public/fonts/LythMono-Regular.ttf` from the site working directory,
extracts box glyphs directly through cmap (no programming ligatures or GSUB
shaping), and normalizes the vertical cell to the 24px tree row. Direct glyph
lookup also avoids Lyth Mono's contextual GSUB lookups that OpenType.js 2.0.0
does not support. The 0.604em advance and glyph stroke width are retained.
The resulting SVG paths are emitted into static HTML; neither the parser nor
the TTF is fetched by the browser. Anime.js controls the shared tree transform
and reveals the persistent trunk through a mask; there is no switch back to
HTML box-drawing text on completion. Labels and normal content remain text.
## Integrity
`SHA256SUMS` records every font and upstream license in this directory. Verify
from this directory with `sha256sum --check SHA256SUMS`. Individual hashes
pin the exact binaries even if upstream tags or release assets change.
+10
View File
@@ -0,0 +1,10 @@
eba110c35ad2631cc85c5610bdaa119c80965606c2ccdafa0cae8390e21cfe12 EBGaramond-Bold.woff2
e7ee9bcf70f45e96230688b30db409c5fd4b90e8ad105cdeb694796f49287606 EBGaramond-BoldItalic.woff2
b83f7e6b6b82cd7ed609448eafb846125635380de1672c7a270796d0cf5fdb12 EBGaramond-Italic.woff2
058611bd968817d532cedeab6acaa055e882d365a88523df00e7917ad7a0f704 EBGaramond-OFL.txt
2028dc06d3c130b4761693481436a32a8e35ed500bf58c25c53de004106125b8 EBGaramond-Regular.ttf
bef2aa73867d2d024a2afeea30b4222bb2995d0e48dbd9eb2af04d0efe84428d EBGaramond-Regular.woff2
cfb84c57ed40f45fc264738d06f102f7465a22c695850c067da26aaa3aaaca68 LythMono-Bold.woff2
81b8afed5a258c059368f5df783ac2697d0d5062c6acefe2a8f70708a583f68e LythMono-LICENSE.md
025a4739a1d0ecdb5454461cba8e46e23c2b70316f432eca07efc823ce9490ef LythMono-Regular.ttf
c498b6b28219d259176bd70feff9975b5a0b65fa549b5bc7959a2138ef1c66a0 LythMono-Regular.woff2
+11 -3
View File
@@ -3,10 +3,12 @@ import { objects, initialObject, childrenOf, treePrefix, objectSignature, interf
import { href } from '../lib/book';
import '../styles/landing.css';
import BoxFrame from './BoxFrame.astro';
import TreePrefix from './TreePrefix.astro';
import { trunkPath, stemWidth, branchY, branchThickness } from '../lib/landing-glyphs.mjs';
const trunkLength = (objects.findLastIndex((object) => object.depth === 2) - 1) * 24 + branchY + branchThickness / 2;
---
<section class="landing" aria-label="ルートからWorldspaceへ" data-landing-stage>
<div class="intro-art" aria-hidden="true">
<svg class="intro-trunk" aria-hidden="true" focusable="false"><path class="trunk-line" d="M0 0V0" /></svg>
<svg class="orbit" viewBox="-300 -300 600 600" focusable="false">
<defs><path id="welcome-orbit" d="M0 -180 C99 -180 180 -99 180 0 C180 99 99 180 0 180 C-99 180 -180 99 -180 0 C-180 -99 -99 -180 0 -180" /></defs>
<text><textPath href="#welcome-orbit" textLength="1080" lengthAdjust="spacing">WELCOME TO WIP  /  DISCOVERY  /  UNDERSTANDING  /  ACTION  / </textPath></text>
@@ -25,12 +27,13 @@ import BoxFrame from './BoxFrame.astro';
<div class="tree-pane">
<div class="tree-caption" data-reveal><span>01 / WORLDSPACE</span><span>↑ ↓ navigate · ← → expand</span></div>
<div class="demo-tree" role="tree" aria-label="Worldspace デモ">
{objects.map((object) => {
<div class="tree-scene">
{objects.map((object, index) => {
const root = object.path === '/';
const children = childrenOf(object.path);
const prefix = treePrefix(object);
return <div class:list={['node-row', { 'root-slot': root }]}>
{!root && <span class="branch-prefix" aria-hidden="true"><span class="branch-spine">{prefix.slice(0, 1)}</span><span class="branch-arm">{prefix.slice(1)}</span></span>}
{!root && <TreePrefix prefix={prefix} id={index} />}
<button
type="button" role="treeitem" class:list={['demo-node', { 'root-mark': root }]}
data-path={object.path} aria-label={root ? 'ルート /' : object.name}
@@ -50,6 +53,10 @@ import BoxFrame from './BoxFrame.astro';
<g class="tap-stream tap-stream-a" transform="translate(5 60) rotate(-66.801409)"><text x="-18" y="0">{'Tap → '.repeat(8)}</text></g>
<g class="tap-stream tap-stream-b" transform="translate(22 60) rotate(-66.801409)"><text x="-18" y="0">{'Tap → '.repeat(8)}</text></g>
</g>
<g class="trunk-anchor" transform="translate(12 64) scale(2.6666666667)" data-stem-width={stemWidth} data-branch-y={branchY} data-branch-thickness={branchThickness}>
<defs><mask id="trunk-growth-mask" maskUnits="userSpaceOnUse" x={-stemWidth} y="0" width={stemWidth * 2} height="10000" mask-type="alpha"><rect class="trunk-reveal" x={-stemWidth} y="0" width={stemWidth * 2} height={trunkLength} fill="white" /></mask></defs>
<g mask="url(#trunk-growth-mask)"><path class="trunk-line" d={trunkPath} transform={`scale(1 ${trunkLength})`} /></g>
</g>
<path class="root-shape" d="M31 4H41L17 60H7Z" />
</svg> : <><span class="node-label">{object.name}</span>{children.length > 0 && <span class="node-indicator" aria-hidden="true">−</span>}</>}
</button>
@@ -58,6 +65,7 @@ import BoxFrame from './BoxFrame.astro';
})}
</div>
</div>
</div>
<section class="ascii-panel text-panel" aria-labelledby="text-view-title" data-reveal>
<BoxFrame />
<div class="panel-title"><h3 id="text-view-title">02 / Text View</h3><span>OBJECT SIGNATURE</span></div>
+19
View File
@@ -0,0 +1,19 @@
---
import { glyphPath, cellWidth, rowHeight, branchY, branchThickness } from '../lib/landing-glyphs.mjs';
interface Props { prefix: string; id: number; }
const { prefix, id } = Astro.props;
const first = prefix[0];
const arm = prefix.slice(1);
---
<span class="branch-prefix" style={`width: ${prefix.length}ch`} aria-hidden="true" data-prefix={prefix}>
<svg class="branch-vector" viewBox={`0 0 ${cellWidth * prefix.length} ${rowHeight}`} data-cell-width={cellWidth} preserveAspectRatio="none" focusable="false">
<defs><clipPath id={`branch-join-${id}`}><rect x={cellWidth / 2} y={branchY - branchThickness / 2} width={cellWidth / 2} height={branchThickness} /></clipPath></defs>
{/* The persistent trunk supplies the first column's vertical ink. Retain
only the horizontal half of root-level joins, cut from the real glyph.
One viewport keeps adjacent glyph edges on the same raster grid. */}
<g class="branch-spine">
{(first === '├' || first === '└') && <path d={glyphPath(first)} clip-path={`url(#branch-join-${id})`} />}
</g>
<g class="branch-arm" transform={`translate(${cellWidth} 0)`}><path d={glyphPath(arm)} /></g>
</svg>
</span>
+1
View File
@@ -27,6 +27,7 @@ const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefin
<meta property="og:type" content="website" />
<meta name="theme-color" content="#123c39" />
<link rel="icon" type="image/svg+xml" href={href('favicon.svg')} />
<link rel="preload" href={href('fonts/LythMono-Regular.woff2')} as="font" type="font/woff2" crossorigin="anonymous" />
</head>
<body class:list={{ 'landing-page': landing }}>
<a class="skip-link" href="#main">本文へスキップ</a>
+1 -1
View File
@@ -24,7 +24,7 @@ export const objects: DemoObject[] = [
export const initialObject = objects.find((object) => object.path === '/issues/123')!;
export const childrenOf = (path: string) => objects.filter((object) => object.parent === path);
/** Each ancestor occupies four mono cells. Keep wires as text, including in SSR. */
/** Each ancestor occupies four mono cells; build-time glyph paths use this layout. */
export function treePrefix(object: DemoObject): string {
if (!object.parent) return '';
const isLast = (item: DemoObject) => childrenOf(item.parent!).at(-1)?.path === item.path;
+49
View File
@@ -0,0 +1,49 @@
// Build-time only: Astro renders these outlines into HTML. The font parser is
// never imported by the browser animation module.
import { readFileSync } from 'node:fs';
import opentype from 'opentype.js';
const { parse } = opentype;
const bytes = readFileSync('public/fonts/LythMono-Regular.ttf');
const font = parse(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
export const rowHeight = 24;
const fontSize = 14;
export const cellWidth = font.charToGlyph('0').advanceWidth * fontSize / font.unitsPerEm;
const vertical = font.charToGlyph('│').getPath(0, 0, fontSize);
const bounds = vertical.getBoundingBox();
const yScale = rowHeight / (bounds.y2 - bounds.y1);
export const stemWidth = bounds.x2 - bounds.x1;
const horizontal = font.charToGlyph('─').getPath(0, 0, fontSize).getBoundingBox();
export const branchY = ((horizontal.y1 + horizontal.y2) / 2 - bounds.y1) * yScale;
export const branchThickness = (horizontal.y2 - horizontal.y1) * yScale;
/** Retain the font's advance and normalize its full vertical cell to one row. */
export function glyphPath(text) {
for (const char of text) {
if (!font.hasChar(char)) throw new Error(`Lyth Mono is missing ${char}`);
}
// Box cells intentionally use direct cmap glyphs, not contextual shaping or
// programming ligatures (Lyth Mono's GSUB includes lookups unsupported by OT2).
const path = new opentype.Path();
[...text].forEach((char, index) => {
path.extend(font.charToGlyph(char).getPath(index * cellWidth, 0, fontSize).commands);
});
for (const command of path.commands) {
for (const key of ['y', 'y1', 'y2']) {
if (key in command) command[key] = (command[key] - bounds.y1) * yScale;
}
}
return path.toPathData(5);
}
// Same filled │ outline for the entire trunk, from growth to the resting tree.
// Normalize length independently of thickness; a mask exposes the growing tip.
for (const command of vertical.commands) {
for (const key of ['x', 'x1', 'x2']) {
if (key in command) command[key] -= (bounds.x1 + bounds.x2) / 2;
}
for (const key of ['y', 'y1', 'y2']) {
if (key in command) command[key] = (command[key] - bounds.y1) / (bounds.y2 - bounds.y1);
}
}
export const trunkPath = vertical.toPathData(5);
+119 -111
View File
@@ -1,37 +1,21 @@
import { animate, createTimeline, cubicBezier } from 'animejs';
import { objects, initialObject, childrenOf, objectSignature, interfaceSignature, interfaceReference } from '../lib/landing-demo';
import { glitchText } from '../lib/landing-ascii.mjs';
const clamp = (value: number) => Math.max(0, Math.min(1, value));
const progress = (time: number, start: number, duration: number) => clamp((time - start) / duration);
// Solve the timing curve's x coordinate, then evaluate y (not a Bézier
// polynomial used directly as time). Curves are assigned by physical role.
function bezier(x1: number, y1: number, x2: number, y2: number) {
const at = (t: number, a: number, b: number) => 3 * (1 - t) ** 2 * t * a + 3 * (1 - t) * t * t * b + t ** 3;
return (value: number) => {
if (value <= 0 || value >= 1) return clamp(value);
let low = 0, high = 1;
for (let i = 0; i < 16; i++) {
const t = (low + high) / 2;
if (at(t, x1, x2) < value) low = t; else high = t;
}
return at((low + high) / 2, y1, y2);
};
}
const timing = {
// One acceleration and one landing, with no intermediate resting pose.
travel: bezier(.42, 0, .32, 1),
// Typography accelerates along a fixed track, not a deforming circle.
release: bezier(.55, 0, .3, 1),
scale: bezier(.6, 0, .2, 1),
// The leading edge slows as it reaches a branch/node connection.
trace: bezier(.22, .3, .25, 1),
travel: cubicBezier(.42, 0, .32, 1),
release: cubicBezier(.55, 0, .3, 1),
scale: cubicBezier(.6, 0, .2, 1),
trace: cubicBezier(.22, .3, .25, 1),
};
const mix = (a: number, b: number, value: number) => a + (b - a) * value;
// Overlapping cues on one clock, not a sequence of completed storyboard poses.
// Hover is independent; every playback starts this same continuous flow.
const flow = {
duration: 3900, release: 560, grow: 1320, converge: 1900,
handoff: 2460, branch: 2320, stagger: 55,
branch: 2320, stagger: 55,
textPanel: 2520, callPanel: 2700, panelDuration: 900,
};
const orbitRadius = 180;
@@ -40,7 +24,13 @@ const circumference = 2 * Math.PI * orbitRadius;
// Scene 4 feeds the text down this rail; the circle's geometry never changes.
const circularTrack = 'M0 -180 A180 180 0 1 1 0 180 A180 180 0 1 1 0 -180';
export function initLanding() {
export async function initLanding() {
// Wait for fixed mono metrics before measuring the landing trajectory.
// A rejected font fetch still leaves usable fallback text and SVG outlines.
await Promise.race([
document.fonts.load('14px "Lyth Mono"').catch(() => []),
new Promise((resolve) => setTimeout(resolve, 2500)),
]);
const element = document.querySelector<HTMLElement>('[data-landing-stage]');
if (!element) return;
const stage: HTMLElement = element;
@@ -49,13 +39,17 @@ export function initLanding() {
const get = <T extends Element = HTMLElement>(selector: string) => stage.querySelector<T>(selector)!;
const root = get<HTMLButtonElement>('.root-mark');
const trunk = get<SVGPathElement>('.trunk-line');
const trunkAnchor = get<SVGGElement>('.trunk-anchor');
const trunkReveal = get<SVGRectElement>('.trunk-reveal');
const scene = get('.tree-scene');
const branchY = Number(trunkAnchor.dataset.branchY);
const branchThickness = Number(trunkAnchor.dataset.branchThickness);
const slot = get('.root-slot');
const tree = get('.demo-tree');
const nodes = [...stage.querySelectorAll<HTMLButtonElement>('.demo-node')];
const rows = nodes.map((node) => node.parentElement!);
const branches = [...stage.querySelectorAll<HTMLElement>('.branch-arm')];
const spines = [...stage.querySelectorAll<HTMLElement>('.branch-spine')];
const lastRootBranch = nodes.findLast((node) => node.getAttribute('aria-level') === '2')!.parentElement!;
const branches = [...stage.querySelectorAll<SVGGElement>('.branch-arm')];
const spines = [...stage.querySelectorAll<SVGGElement>('.branch-spine')];
const reveals = [...stage.querySelectorAll<HTMLElement>('[data-reveal]')];
const orbit = get<SVGSVGElement>('.orbit');
const orbitPath = get<SVGPathElement>('#welcome-orbit');
@@ -71,14 +65,12 @@ export function initLanding() {
const abort = new AbortController();
let state: 'armed' | 'playing' | 'complete' = 'complete';
let selected = initialObject;
let frame = 0;
let startTime = 0;
let keyboardStarted = false;
let animation: Animation | undefined;
let animation: ReturnType<typeof animate> | undefined;
const collapsed = new Set<string>();
const panels = [...stage.querySelectorAll<HTMLElement>('.ascii-panel')];
const glitchTargets = [...stage.querySelectorAll<HTMLElement>([
'.branch-arm', '.node-label', '.box-fill', '.box-column', '.box-corner',
'.node-label', '.box-fill', '.box-column', '.box-corner',
'.panel-title h3', '.panel-title > span', '[data-object-signature]', '[data-interface-signature]',
'[data-target]', '[data-operation]', '.result-label > span', '[data-result]',
].join(','))].map((element, seed) => {
@@ -111,6 +103,65 @@ export function initLanding() {
panels.forEach((panel) => panel.removeAttribute('data-glitching'));
}
const cues = { time: 0, travel: 0, scale: 7.2, extension: 0, converge: 0, release: 0, turn: -28, trunkOpacity: 0 };
const timeline = createTimeline({
autoplay: false,
defaults: { ease: 'linear' },
onRender: () => {
if (state === 'complete') return;
try { render(); } catch { finish(); }
},
onComplete: () => finish(),
})
.label('growth', flow.grow)
.label('branches', flow.branch)
.label('panels', flow.textPanel)
.add(cues, { time: [0, flow.duration], duration: flow.duration }, 0)
.add(cues, { travel: [0, 1], duration: 3000, ease: timing.travel }, 0)
.add(cues, { scale: [7.2, 1], duration: 2650, ease: timing.scale }, 350)
.add(cues, { extension: [0, 1], duration: 1000, ease: timing.trace }, 'growth')
.add(cues, { trunkOpacity: [0, 1], duration: 100 }, 'growth')
.add(cues, { converge: [0, 1], duration: 560, ease: timing.travel }, flow.converge)
.add(cues, { release: [0, 1], duration: 1620, ease: timing.release }, flow.release)
.add(cues, { turn: [-28, 0], duration: flow.release, ease: timing.trace }, 0)
.add(invitation, { opacity: [1, 0], duration: 220 }, 0)
.add(orbit, { opacity: [0, 1], duration: 300 }, 0)
.add(html, { '--landing-chrome': [0, 1], duration: 600 }, 3000);
branches.forEach((wire, index) => {
const start = flow.branch + index * flow.stagger;
timeline.add([wire, spines[index]], { opacity: [0, 1], duration: 160 }, start)
.add(wire, { clipPath: ['inset(0 100% 0 0)', 'inset(0 0% 0 0)'], duration: 520, ease: timing.trace }, start);
});
nodes.slice(1).forEach((node, index) => {
timeline.add(node, { opacity: [0, 1], duration: 220 }, flow.branch + 180 + index * flow.stagger);
});
reveals.forEach((element, index) => {
const isPanel = element.classList.contains('ascii-panel');
const start = isPanel ? (element.classList.contains('call-panel') ? flow.callPanel : flow.textPanel) : 2540 + index * 35;
timeline.add(element, { opacity: [0, 1], duration: isPanel ? flow.panelDuration / 6 : 420 }, start);
});
const tapLoops = [...stage.querySelectorAll<SVGTextElement>('.tap-stream text')].map((text, index) => animate(text, {
// Measure one repeated phrase: Lyth Mono's standard arrows can be wider
// than one cell, so a hard-coded six-ch translation would jump at the loop.
translateX: [-text.getComputedTextLength() / 8, 0], duration: 1400, ease: 'linear', loop: true, reversed: index === 1, autoplay: false,
}));
function stopHover() {
delete stage.dataset.hover;
tapLoops.forEach((loop) => loop.pause());
}
function restingLength() {
const last = nodes.findLast((node) => node.getAttribute('aria-level') === '2' && !node.parentElement!.hidden)?.parentElement;
return last ? last.offsetTop + branchY + branchThickness / 2 - slot.offsetTop - slot.offsetHeight : 0;
}
function drawTrunk(length: number) {
// One glyph, one origin and one mask. There is no SVG-to-text handoff.
trunk.setAttribute('transform', 'scale(1 ' + Math.max(1, length) + ')');
trunkReveal.setAttribute('height', String(Math.max(0, length)));
trunk.dataset.length = String(Math.max(0, length));
}
function select(index: number, focus = false) {
selected = objects[index];
nodes.forEach((node, i) => {
@@ -144,6 +195,7 @@ export function initLanding() {
if (indicator) indicator.textContent = collapsed.has(object.path) ? '+' : '−';
}
});
drawTrunk(restingLength());
}
nodes.forEach((node, index) => node.addEventListener('click', (event) => {
@@ -193,21 +245,24 @@ export function initLanding() {
callState.textContent = 'RETURNED';
callState.dataset.callState = 'returned';
status.textContent = `${selected.path} の read_body が完了。ローカルサンプル / 外部通信なし。`;
if (!motion.matches) animation = result.animate([{ opacity: .25, transform: 'translateY(5px)' }, { opacity: 1, transform: 'translateY(0)' }], { duration: 280, easing: 'cubic-bezier(.2,.7,.2,1)' });
if (!motion.matches) animation = animate(result, { opacity: [.25, 1], translateY: [5, 0], duration: 280, ease: cubicBezier(.2, .7, .2, 1) });
});
function finish() {
cancelAnimationFrame(frame);
timeline.pause();
state = 'complete';
stage.dataset.phase = 'complete';
delete html.dataset.landing;
abort.abort();
delete stage.dataset.hover;
stopHover();
restoreGlitch();
root.style.removeProperty('transform');
scene.style.removeProperty('transform');
scene.style.removeProperty('will-change');
rows.slice(1).forEach((row) => { row.style.removeProperty('transform'); });
html.style.removeProperty('--landing-chrome');
trunk.style.opacity = '0';
trunk.style.opacity = '1';
trunk.style.removeProperty('fill');
drawTrunk(restingLength());
root.setAttribute('aria-label', 'ルート /');
invitation.style.removeProperty('opacity');
nodes.forEach((node) => {
@@ -231,15 +286,14 @@ export function initLanding() {
select(objects.indexOf(selected), keyboardStarted || document.activeElement === skip || document.activeElement === root);
}
// One trajectory carries the root AND emerging branches, so growth can begin
// before docking without disconnecting the SVG trunk from the text grid.
function render(time: number) {
const target = slot.getBoundingClientRect();
// Anime.js owns the clock and easing; this renderer resolves only the
// responsive spatial geometry. Every tree element shares one transform.
function render() {
const time = cues.time;
const treeTarget = tree.getBoundingClientRect();
// offsetWidth rounds fractional mono cells and would shift the SVG axis.
const endX = target.left + parseFloat(getComputedStyle(slot).paddingLeft) + parseFloat(getComputedStyle(root).width) / 2;
const endY = target.bottom - root.offsetHeight / 2;
const travel = timing.travel(progress(time, 0, 3000));
const endX = treeTarget.left + parseFloat(getComputedStyle(slot).paddingLeft) + parseFloat(getComputedStyle(root).width) / 2;
const endY = treeTarget.top + slot.offsetTop + slot.offsetHeight - root.offsetHeight / 2;
const { travel, scale, extension, converge } = cues;
const remaining = 1 - travel;
const startX = innerWidth / 2, startY = innerHeight / 2;
const centreX = remaining ** 3 * startX
@@ -248,97 +302,44 @@ export function initLanding() {
const centreY = remaining ** 3 * startY
+ 3 * remaining ** 2 * travel * (startY + Math.min(48, innerHeight * .08))
+ 3 * remaining * travel ** 2 * (endY + Math.min(90, innerHeight * .12)) + travel ** 3 * endY;
const scale = mix(7.2, 1, timing.scale(progress(time, 350, 2650)));
const dx = centreX - endX, dy = centreY - endY;
root.style.transform = `translate3d(${dx}px, ${dy}px, 0) scale(${scale})`;
rows.slice(1).forEach((row) => {
// offsetTop stays in layout coordinates, unaffected by last frame's transform.
const x = dx + (scale - 1) * (treeTarget.left - endX);
const y = dy + (scale - 1) * (treeTarget.top + row.offsetTop - endY);
row.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
});
scene.style.transformOrigin = [endX - treeTarget.left, endY - treeTarget.top].map((v) => v + 'px').join(' ');
scene.style.transform = 'translate3d(' + (centreX - endX) + 'px, ' + (centreY - endY) + 'px, 0) scale(' + scale + ')';
// Growth overlaps the moving/shrinking root. It crosses the old circle only
// after the lower letters have passed, then converges on the moving grid.
const extension = timing.trace(progress(time, flow.grow, 1000));
const converge = timing.travel(progress(time, flow.converge, 560));
const longLength = Math.max(0, innerHeight * .4 - root.offsetHeight * 7.2 / 2 - 8);
// Follow the slash's actual foot (x=12), not its bounding-box centre.
// y=64 is the row boundary, a fixed four SVG units below the ink at y=60.
const origin = new DOMPoint(12, 64).matrixTransform(get<SVGSVGElement>('.root-vector').getScreenCTM()!);
const lineStartY = origin.y;
const finalLength = lastRootBranch.offsetTop + lastRootBranch.offsetHeight / 2 - slot.offsetTop - slot.offsetHeight;
const tipY = lineStartY + mix(longLength * extension, finalLength * scale, converge);
const handoff = progress(time, flow.handoff, 280);
trunk.setAttribute('d', `M${origin.x} ${lineStartY} V${tipY}`);
// Keep the 1px resting stroke proportional to the slash's shared scale.
// Convergence changes the trunk's length, never its relative thickness.
trunk.style.strokeWidth = String(scale);
drawTrunk(mix(longLength * extension / scale, restingLength(), converge));
const ink = [[32, 142], [34, 152], [31, 134]].map(([from, to]) => Math.round(mix(from, to, converge)));
trunk.style.stroke = `rgb(${ink.join(',')})`;
trunk.style.opacity = String(progress(time, flow.grow, 100) * (1 - handoff));
invitation.style.opacity = String(1 - progress(time, 0, 220));
trunk.style.fill = 'rgb(' + ink.join(',') + ')';
trunk.style.opacity = String(cues.trunkOpacity);
// Keep the circle centred on the moving root until the narrow viewport
// needs room for its trailing letters; its radius is never scaled or warped.
const orbitScale = parseFloat(getComputedStyle(orbit).width) / 600;
const radius = orbitRadius * orbitScale;
const drift = 120 * (1 - Math.exp(-time / 600));
const release = timing.release(progress(time, flow.release, 1620));
const release = cues.release;
const exitLength = innerWidth / orbitScale + circumference;
orbitPath.setAttribute('d', circularTrack + (time >= flow.release ? ` H${exitLength}` : ''));
orbit.style.opacity = String(progress(time, 0, 300));
const orbitX = Math.max(radius + 16, centreX);
const orbitY = centreY;
const turn = mix(-28, 0, timing.trace(progress(time, 0, flow.release)));
const turn = cues.turn;
orbit.style.transform = `translate(calc(-50% + ${orbitX - startX}px), calc(-50% + ${orbitY - startY}px)) rotate(${turn}deg)`;
textPath.setAttribute('startOffset', String(12 + drift + release * (circumference + exitLength)));
spines.forEach((wire) => { wire.style.opacity = String(handoff); });
branches.forEach((wire, index) => {
const value = timing.trace(progress(time, flow.branch + index * flow.stagger, 520));
wire.style.opacity = String(clamp(value * 4));
wire.style.clipPath = `inset(0 ${(1 - value) * 100}% 0 0)`;
});
nodes.slice(1).forEach((node, index) => {
node.style.opacity = String(progress(time, flow.branch + 180 + index * flow.stagger, 220));
});
reveals.forEach((element, index) => {
const isPanel = element.classList.contains('ascii-panel');
const start = isPanel ? (element.classList.contains('call-panel') ? flow.callPanel : flow.textPanel) : 2540 + index * 35;
const value = progress(time, start, isPanel ? flow.panelDuration : 420);
element.style.opacity = String(isPanel ? clamp(value * 6) : value);
});
html.style.setProperty('--landing-chrome', String(progress(time, 3000, 600)));
renderGlitch(time);
// A diagnostic label, not a playback gate or a storyboard step.
stage.dataset.phase = 'flow';
}
function tick(now: number) {
if (state !== 'playing') return;
const time = now - startTime;
if (time >= flow.duration) return finish();
try {
render(time);
frame = requestAnimationFrame(tick);
} catch {
// Fail open: the document and demo are more important than the animation.
finish();
}
}
function start(fromKeyboard = false) {
if (state !== 'armed') return;
if (motion.matches) return finish();
keyboardStarted = fromKeyboard;
state = 'playing';
html.dataset.landing = 'playing';
delete stage.dataset.hover;
startTime = performance.now();
render(0);
root.style.willChange = 'transform';
frame = requestAnimationFrame(tick);
stopHover();
scene.style.willChange = 'transform';
render();
timeline.play();
}
if (html.dataset.landing === 'boot' && !motion.matches && !location.hash && scrollY < 20) {
@@ -350,14 +351,18 @@ export function initLanding() {
root.disabled = false;
root.tabIndex = 0;
root.setAttribute('aria-label', 'Worldspaceを開く');
render(0);
timeline.seek(0, true);
render();
stage.dataset.phase = 'armed';
invitation.style.opacity = '1';
const signal = abort.signal;
root.addEventListener('pointerenter', (event) => {
if (state === 'armed' && event.pointerType !== 'touch') stage.dataset.hover = 'true';
if (state === 'armed' && event.pointerType !== 'touch') {
stage.dataset.hover = 'true';
tapLoops.forEach((loop) => loop.play());
}
}, { signal });
root.addEventListener('pointerleave', () => { delete stage.dataset.hover; }, { signal });
root.addEventListener('pointerleave', stopHover, { signal });
document.addEventListener('click', (event) => {
if ((event.target as Element).closest('.intro-skip, .skip-link')) return finish();
start();
@@ -383,7 +388,8 @@ export function initLanding() {
}
const resize = new ResizeObserver(() => {
if (state === 'armed') { render(0); stage.dataset.phase = 'armed'; invitation.style.opacity = '1'; }
if (state === 'armed') { render(); stage.dataset.phase = 'armed'; invitation.style.opacity = '1'; }
else if (state === 'complete') drawTrunk(restingLength());
});
resize.observe(get('.tree-pane'));
// Resuming a backgrounded page should never leave a half-finished intro.
@@ -397,6 +403,8 @@ export function initLanding() {
if (state !== 'complete') finish();
animation?.cancel();
if (event.persisted) return;
timeline.cancel();
tapLoops.forEach((loop) => loop.cancel());
resize.disconnect();
document.removeEventListener('visibilitychange', onVisibility);
motion.removeEventListener('change', onMotion);
+44
View File
@@ -0,0 +1,44 @@
/* Self-hosted, unmodified upstream fonts; revisions and licenses: /fonts/README.md.
Vite resolves these public asset URLs against the configured site base. */
@font-face {
font-family: 'Lyth Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/LythMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'Lyth Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/LythMono-Bold.woff2') format('woff2');
}
@font-face {
font-family: 'EB Garamond';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/EBGaramond-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'EB Garamond';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/EBGaramond-Bold.woff2') format('woff2');
}
@font-face {
font-family: 'EB Garamond';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('/fonts/EBGaramond-Italic.woff2') format('woff2');
}
@font-face {
font-family: 'EB Garamond';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url('/fonts/EBGaramond-BoldItalic.woff2') format('woff2');
}
+5 -2
View File
@@ -1,3 +1,5 @@
@import './fonts.css';
:root {
color-scheme: light;
--paper: #f7f7f2;
@@ -8,8 +10,9 @@
--green: #123c39;
--accent: #d8ecc9;
--soft: #edf1e8;
--mono: ui-monospace, 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
font-family: 'Noto Sans JP', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--mono: 'Lyth Mono', ui-monospace, 'SFMono-Regular', Consolas, 'Liberation Mono', 'Noto Sans Mono CJK JP', monospace;
--serif: 'EB Garamond', 'Noto Serif JP', 'Hiragino Mincho ProN', 'Yu Mincho', 'YuMincho', serif;
font-family: var(--serif);
font-size: 16px;
line-height: 1.8;
color: var(--ink);
+9 -12
View File
@@ -3,7 +3,6 @@
.landing-page {
--paper: #fafaf8; --ink: #20221f; --muted: #686d64; --green: #283e25;
--accent: #dcf9b0; --line: #d9ddd3; --row: 24px;
--mono: 'SFMono-Regular', Consolas, 'Liberation Mono', 'Noto Sans Mono CJK JP', monospace;
font: 14px/var(--row) var(--mono); background: var(--paper);
}
.landing-page :is(button, input, code, pre) { font: inherit; }
@@ -31,18 +30,18 @@ html[data-landing] .landing-page .site-header { opacity: 0; }
.tree-pane { min-width: 0; }
.tree-caption { display: flex; justify-content: space-between; gap: 2ch; color: var(--muted); height: var(--row); margin: 0; white-space: nowrap; }
.tree-caption span:last-child { font-size: inherit; }
.demo-tree { position: relative; }
.demo-tree, .tree-scene { position: relative; }
.tree-scene { transform-origin: 0 0; }
.node-row { position: relative; padding-left: 1ch; height: var(--row); display: flex; align-items: center; white-space: nowrap; }
.node-row[hidden] { display: none; }
.node-row:not(.root-slot) { transform-origin: 0 0; }
/* The slash's lower edge is centred at x=12, not the viewBox centre x=24.
Offset its box by 12/64 of the row so the foot sits on the mono spine. */
.node-row.root-slot { padding: 0 0 0 calc(var(--row) * .1875); height: calc(3 * var(--row)); align-items: end; }
/* These are real Unicode box-drawing characters, not SVG paths or borders.
Scale only their vertical ink to cover the leading between mono baselines. */
.branch-prefix { display: inline-flex; flex: 0 0 auto; white-space: pre; font: 14px/14px var(--mono); height: 14px; color: #8e9886; transform: scaleY(1.7142857143); transform-origin: center; pointer-events: none; }
.branch-spine { flex: 0 0 1ch; }
.branch-arm { display: block; }
/* Lyth Mono outlines are emitted at build time. They remain the same SVG
paths through growth, landing, collapsed states and JavaScript-free SSR. */
.branch-prefix { display: block; flex: 0 0 auto; height: var(--row); color: #8e9886; pointer-events: none; }
.branch-vector { display: block; width: 100%; height: var(--row); fill: currentColor; overflow: visible; }
.demo-node { position: relative; flex: 0 0 auto; border: 0; border-radius: 0; padding: 0 1ch; margin: 0; height: var(--row); display: flex; align-items: center; gap: 1ch; cursor: pointer; background: transparent; text-align: left; transition: background .18s; }
.demo-node[aria-selected="true"] { background: var(--accent); color: #273621; }
.demo-node:hover { background: #eaf0e2; }
@@ -123,8 +122,9 @@ html[data-landing] .root-mark { color: var(--ink); }
html[data-landing="boot"] .root-mark { position: fixed; left: calc(50vw - 1.5ch); top: calc(50svh - var(--row) / 2); transform: scale(7.2); }
html[data-landing] .intro-art { display: block; position: fixed; z-index: 10; width: 100%; height: 100svh; inset: 0; pointer-events: none; }
.orbit { position: absolute; left: 50%; top: 50%; width: min(640px, 112vw); height: min(640px, 112vw); overflow: visible; transform: translate(-50%, -50%); opacity: 0; }
.intro-trunk { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.trunk-line { fill: none; stroke: var(--ink); stroke-width: 10; stroke-linecap: butt; opacity: 0; }
.trunk-anchor { pointer-events: none; }
.trunk-line { fill: #8e9886; stroke: none; }
html[data-landing="boot"] .trunk-line { opacity: 0; }
.orbit text { fill: var(--ink); font: 500 17px var(--mono); letter-spacing: 1.5px; }
/* The mask stays in the slash's coordinate system (y=4…60), outside the
rotated text lanes. Thus both streams enter/leave at horizontal edges. */
@@ -132,9 +132,6 @@ html[data-landing] .intro-art { display: block; position: fixed; z-index: 10; wi
.tap-stream { fill: currentColor; font: 500 3.7px var(--mono); letter-spacing: .74px; opacity: 0; }
.tap-stream text { white-space: pre; }
.landing[data-hover="true"] .tap-stream { opacity: 1; }
.landing[data-hover="true"] .tap-stream-a text { animation: tap-forward 1.4s linear infinite; }
.landing[data-hover="true"] .tap-stream-b text { animation: tap-forward 1.4s linear infinite reverse; }
@keyframes tap-forward { from { transform: translateX(calc(-6ch - 4.44px)); } to { transform: translateX(0); } }
html[data-landing] .intro-invitation { display: block; position: fixed; left: 2ch; right: 2ch; bottom: calc(4 * var(--row)); text-align: center; z-index: 11; pointer-events: none; color: var(--muted); }
.intro-invitation p { margin: var(--row) 0 0; }
html[data-landing] .intro-skip { display: block; position: fixed; right: 2ch; bottom: var(--row); z-index: 40; border: 0; padding: 0 1ch; height: var(--row); background: transparent; color: var(--muted); cursor: pointer; }
+91
View File
@@ -0,0 +1,91 @@
import { expect, test } from '@playwright/test';
test.use({ javaScriptEnabled: true });
test('self-hosted Lyth Mono and EB Garamond load with the configured base', async ({ page }, testInfo) => {
const external: string[] = [];
page.on('request', (request) => {
if (request.resourceType() === 'font' && !request.url().startsWith(testInfo.project.use.baseURL!)) external.push(request.url());
});
await page.goto('./');
const fonts = await page.evaluate(async () => {
await document.fonts.ready;
const mono = await document.fonts.load('14px "Lyth Mono"', '0W│├└─');
const bold = await document.fonts.load('700 14px "Lyth Mono"', 'WIP');
const serif = await document.fonts.load('16px "EB Garamond"', 'Worldspace');
const italic = await document.fonts.load('italic 16px "EB Garamond"', 'Worldspace');
return {
mono: mono.map((font) => font.status), bold: bold.map((font) => font.status),
serif: serif.map((font) => font.status), italic: italic.map((font) => font.status),
stack: getComputedStyle(document.body).fontFamily,
prefix: document.querySelector('.branch-prefix')!.textContent!.trim(),
};
});
expect(fonts.mono).toEqual(['loaded']);
expect(fonts.bold).toEqual(['loaded']);
expect(fonts.serif).toEqual(['loaded']);
expect(fonts.italic).toEqual(['loaded']);
expect(fonts.stack).toContain('Lyth Mono');
expect(fonts.prefix).toBe(''); // SSR uses the same glyph paths, not HTML wires.
expect(external).toEqual([]);
await page.goto('./book/1-concept/');
expect(await page.locator('.prose').evaluate((element) => getComputedStyle(element).fontFamily)).toContain('EB Garamond');
await page.screenshot({ path: testInfo.outputPath('eb-garamond-book.png') });
});
test.describe('static font and tree rendering', () => {
test.use({ javaScriptEnabled: false, deviceScaleFactor: 4 });
test('glyph joins paint continuously across SVG cell boundaries', async ({ page }) => {
await page.goto('./');
await page.evaluate(() => document.fonts.ready);
const joins = await page.locator('.demo-node[aria-level="2"]').evaluateAll((nodes) => nodes.map((node) => {
const svg = node.parentElement!.querySelector<SVGSVGElement>('.branch-vector')!;
const p = new DOMPoint(Number(svg.dataset.cellWidth) / 2, 12).matrixTransform(svg.getScreenCTM()!);
return { x: p.x, y: p.y };
}));
for (const join of joins) {
const clip = { x: Math.floor(join.x), y: Math.floor(join.y) - 1, width: 14, height: 3 };
const png = (await page.screenshot({ clip })).toString('base64');
const columns = await page.evaluate(async (png) => {
const image = new Image(); image.src = 'data:image/png;base64,' + png;
await image.decode();
const canvas = document.createElement('canvas');
canvas.width = image.width; canvas.height = image.height;
const ctx = canvas.getContext('2d')!; ctx.drawImage(image, 0, 0);
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
return Array.from({ length: canvas.width }, (_, x) => {
let darkest = 765;
for (let y = 0; y < canvas.height; y++) {
const i = (y * canvas.width + x) * 4;
darkest = Math.min(darkest, data[i] + data[i + 1] + data[i + 2]);
}
return darkest;
});
}, png);
// Ignore only the outer antialiasing fringe at the trunk's left edge.
const start = Math.ceil((join.x - clip.x + .25) * 4);
expect(columns.slice(start).every((ink) => ink < 710), JSON.stringify(columns)).toBe(true);
}
});
test('the same SVG trunk and Lyth Mono joins are visible without JavaScript', async ({ page }, testInfo) => {
await page.goto('./');
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
await expect(page.locator('.trunk-line')).toBeVisible();
await expect(page.locator('.trunk-line')).toHaveCSS('stroke', 'none');
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├── ');
await expect(page.locator('.branch-prefix').first().locator('path')).toHaveCount(2);
await expect(page.getByRole('link', { name: 'Book', exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({ path: testInfo.outputPath('lyth-mono-static.png') });
});
});
test('failed font downloads do not strand the animation or interactive demo', async ({ page }) => {
await page.route('**/*.woff2', (route) => route.abort());
await page.goto('./');
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-phase', 'armed');
await page.keyboard.press('Escape');
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-phase', 'complete');
await page.getByRole('button', { name: 'Call', exact: false }).click();
await expect(page.locator('[data-result]')).toContainText('Give every capability a place.');
});
+270 -61
View File
@@ -3,6 +3,35 @@ import { expect, test } from '@playwright/test';
test.use({ javaScriptEnabled: true });
const stage = '[data-landing-stage]';
// Filled glyph bounds describe the ink; the anchor describes its origin.
// Path length/start points describe an outline perimeter, not the trunk axis.
async function trunkGeometry(page: import('@playwright/test').Page) {
return page.evaluate(() => {
const path = document.querySelector<SVGPathElement>('.trunk-line')!;
const anchor = document.querySelector<SVGGElement>('.trunk-anchor')!;
const reveal = document.querySelector<SVGRectElement>('.trunk-reveal')!;
const bounds = path.getBBox();
const matrix = path.getScreenCTM()!;
const anchorMatrix = anchor.getScreenCTM()!;
const start = new DOMPoint(0, 0).matrixTransform(anchorMatrix);
const tip = new DOMPoint(0, reveal.height.baseVal.value).matrixTransform(anchorMatrix);
const slash = document.querySelector('.root-shape')!.getBoundingClientRect();
const foot = new DOMPoint(12, 60).matrixTransform(document.querySelector<SVGSVGElement>('.root-vector')!.getScreenCTM()!);
return {
width: bounds.width * Math.hypot(matrix.a, matrix.b),
length: reveal.height.baseVal.value,
opacity: Number(getComputedStyle(path).opacity),
stemWidth: Number(anchor.dataset.stemWidth),
slashHeight: slash.height,
anchorX: (start.x - slash.left) / slash.width,
gapRatio: (start.y - slash.bottom) / slash.height,
dx: tip.x - start.x, dy: tip.y - start.y,
axis: start.x - foot.x,
gap: start.y - document.querySelector('.root-mark')!.getBoundingClientRect().bottom,
top: start.y, bottom: tip.y, x: start.x,
};
});
}
async function armed(page: import('@playwright/test').Page) {
await page.goto('./');
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed');
@@ -35,6 +64,8 @@ for (const trigger of ['click', 'wheel', 'key']) {
}
test('hover streams are masked at the actual slash edges throughout the loop', async ({ page }, testInfo) => {
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
await armed(page);
const root = page.locator('.root-mark');
const box = (await root.boundingBox())!;
@@ -50,13 +81,13 @@ test('hover streams are masked at the actual slash edges throughout the loop', a
});
expect(mask.top).toBeCloseTo(shape.y, 1);
expect(mask.bottom).toBeCloseTo(shape.y + shape.height, 1);
for (const time of [0, 700, 1399]) {
await page.locator('.tap-stream text').evaluateAll((texts, time) => {
texts.forEach((text) => text.getAnimations().forEach((animation) => {
animation.pause();
animation.currentTime = time;
}));
}, time);
let previousTime = 0;
const transforms: string[][] = [];
for (const time of [16, 700, 1399, 1416, 2100, 2799]) {
// Anime.js advances on requestAnimationFrame, not Element.getAnimations().
await page.clock.runFor(time - previousTime);
transforms.push(await page.locator('.tap-stream text').evaluateAll((texts) => texts.map((text) => getComputedStyle(text).transform)));
previousTime = time;
const after = (await page.screenshot({ clip, path: testInfo.outputPath(`hover-mask-${time}.png`) })).toString('base64');
const ink = await page.evaluate(async ({ before, after }) => {
const pixels = async (base64: string) => {
@@ -83,12 +114,64 @@ test('hover streams are masked at the actual slash edges throughout the loop', a
expect(ink.minY + clip.y).toBeGreaterThanOrEqual(Math.floor(shape.y));
expect(ink.maxY + clip.y).toBeLessThanOrEqual(Math.ceil(shape.y + shape.height));
}
for (let stream = 0; stream < 2; stream++) {
expect(transforms[1][stream]).not.toBe(transforms[0][stream]);
expect(transforms[2][stream]).not.toBe(transforms[1][stream]);
expect(transforms[4][stream]).not.toBe(transforms[3][stream]);
}
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed');
await page.mouse.move(0, 0);
await expect(page.locator('.tap-stream-a')).toHaveCSS('opacity', '0');
await expect(page.locator('.tap-stream-b')).toHaveCSS('opacity', '0');
});
test.describe('persistent vertical trunk', () => {
test.use({ deviceScaleFactor: 4 });
test('painted top stays below the slash and at the SVG origin through completion', async ({ page }, testInfo) => {
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
await armed(page);
await page.keyboard.press('Enter');
// Isolate the line ink: the slash could otherwise occlude a glyph extending
// upward behind it. Visibility does not change geometry or the timeline.
await page.locator('.root-shape').evaluate((shape) => { (shape as SVGElement).style.visibility = 'hidden'; });
let previous = 0;
for (const time of [2440, 2600, 2800, 4000]) {
await page.clock.runFor(time - previous);
const points = await page.locator('.root-vector').evaluate((svg) => {
const matrix = (svg as SVGSVGElement).getScreenCTM()!;
const origin = new DOMPoint(12, 64).matrixTransform(matrix);
const foot = new DOMPoint(12, 60).matrixTransform(matrix);
return { x: origin.x, y: origin.y, footY: foot.y };
});
const clip = { x: Math.floor(points.x) - 2, y: Math.floor(points.y) - 8, width: 5, height: 20 };
const png = (await page.screenshot({ clip, path: testInfo.outputPath(`origin-${time}.png`) })).toString('base64');
const top = await page.evaluate(async (png) => {
const image = new Image(); image.src = 'data:image/png;base64,' + png;
await image.decode();
const canvas = document.createElement('canvas');
canvas.width = image.width; canvas.height = image.height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(image, 0, 0);
const data = ctx.getImageData(0, 0, image.width, image.height).data;
for (let i = 0; i < data.length; i += 4) {
if (data[i] + data[i + 1] + data[i + 2] < 690) return Math.floor(i / 4 / image.width) / devicePixelRatio;
}
return null;
}, png);
expect(top).not.toBeNull();
const paintedTop = clip.y + top!;
expect.soft(paintedTop, `line top below slash at ${time}ms`).toBeGreaterThan(points.footY);
expect.soft(Math.abs(paintedTop - points.y), `line top matches origin at ${time}ms`).toBeLessThan(.3);
previous = time;
}
await complete(page);
await page.locator('.root-shape').evaluate((shape) => { (shape as SVGElement).style.removeProperty('visibility'); });
await page.locator('.demo-tree').screenshot({ path: testInfo.outputPath('completed-origin.png') });
});
});
test('completed tree aligns the slash foot with rendered box-drawing ink', async ({ page }, testInfo) => {
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
@@ -134,28 +217,18 @@ test('trunk thickness and origin stay proportional to the slash throughout scali
let previousWidth = Infinity;
for (const time of [1360, 1520, 1760, 2000, 2240, 2480, 2700]) {
await page.clock.runFor(time - previousTime);
const ink = await page.evaluate(() => {
const path = document.querySelector<SVGPathElement>('.trunk-line')!;
const matrix = path.getScreenCTM()!;
const stroke = parseFloat(getComputedStyle(path).strokeWidth) * Math.hypot(matrix.a, matrix.b);
const slash = document.querySelector('.root-shape')!.getBoundingClientRect();
const start = path.getPointAtLength(0).matrixTransform(matrix);
return {
stroke, slashHeight: slash.height, opacity: Number(getComputedStyle(path).opacity),
anchorX: (start.x - slash.left) / slash.width,
gapRatio: (start.y - slash.bottom) / slash.height,
};
});
const ink = await trunkGeometry(page);
expect(ink.opacity).toBeGreaterThan(0);
// At rest: 1px trunk, 21px of actual slash ink (56/64 of its 24px box).
expect(ink.stroke / ink.slashHeight).toBeCloseTo(1 / 21, 5);
// At rest: the Lyth Mono filled stem is 1.064px, slash ink is 21px.
expect(ink.stemWidth).toBeCloseTo(1.064, 5);
expect(ink.width / ink.slashHeight).toBeCloseTo(ink.stemWidth / 21, 5);
// The foot is at x=12 within ink bounds x=7…41, not their centre.
expect(ink.anchorX).toBeCloseTo(5 / 34, 5);
expect(ink.gapRatio).toBeCloseTo(1 / 14, 5);
expect(ink.stroke).toBeLessThan(previousWidth);
expect(ink.width).toBeLessThan(previousWidth);
if ([1520, 2000, 2480].includes(time)) await page.screenshot({ path: testInfo.outputPath(`proportions-${time}.png`) });
previousTime = time;
previousWidth = ink.stroke;
previousWidth = ink.width;
}
await page.clock.runFor(1400);
await complete(page);
@@ -215,56 +288,56 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
expect(tangent.dy).toBeCloseTo(0, 1);
expect(tangent.y).toBeCloseTo(-180, 1);
expect(Number(await page.locator('.orbit textPath').getAttribute('startOffset'))).toBeGreaterThan(arcLength / 2 + 100);
const earlyLength = await page.locator('.trunk-line').evaluate((path) => (path as SVGPathElement).getTotalLength());
const early = await trunkGeometry(page);
const earlyLength = early.length;
const earlyRoot = (await root.boundingBox())!;
expect(earlyLength).toBeGreaterThan(10);
expect(early.dy).toBeGreaterThan(10);
await page.screenshot({ path: testInfo.outputPath('04-release-growth.png') });
await page.clock.runFor(260);
const extendedLength = await page.locator('.trunk-line').evaluate((path) => (path as SVGPathElement).getTotalLength());
const extended = await trunkGeometry(page);
const extendedLength = extended.length;
const extendedRoot = (await root.boundingBox())!;
expect(extendedLength).toBeGreaterThan(earlyLength * 1.4);
expect(extendedLength).toBeGreaterThan(page.viewportSize()!.height * .15);
expect(extended.dy).toBeGreaterThan(page.viewportSize()!.height * .15);
// The trunk grows WHILE the root travels and shrinks, never after a hold.
expect(extendedRoot.height).toBeLessThan(earlyRoot.height - 5);
expect(extendedRoot.x + extendedRoot.width / 2).toBeLessThan(earlyRoot.x + earlyRoot.width / 2 - 2);
await expect(page.locator('.demo-node:not(.root-mark)').first()).toHaveCSS('opacity', '0');
const extended = await page.evaluate(() => {
const path = document.querySelector<SVGPathElement>('.trunk-line')!;
const start = path.getPointAtLength(0).matrixTransform(path.getScreenCTM()!);
const tip = path.getPointAtLength(path.getTotalLength()).matrixTransform(path.getScreenCTM()!);
const root = document.querySelector('.root-mark')!.getBoundingClientRect();
const foot = new DOMPoint(12, 60).matrixTransform(document.querySelector<SVGSVGElement>('.root-vector')!.getScreenCTM()!);
return { dx: tip.x - start.x, dy: tip.y - start.y, axis: start.x - foot.x, gap: start.y - root.bottom, bottom: tip.y };
});
expect(Math.abs(extended.dx)).toBeLessThan(.1);
expect(Math.abs(extended.axis)).toBeLessThan(.1);
expect(extended.dy).toBeGreaterThan(0);
expect(extended.gap).toBeCloseTo(0, 1);
expect(extended.bottom).toBeLessThan(page.viewportSize()!.height);
await page.screenshot({ path: testInfo.outputPath('04-travelling-trunk.png') });
await page.clock.runFor(920); // 2600ms: moving tree, SVG handoff AND panel onset.
const handoff = await page.evaluate(() => {
const path = document.querySelector<SVGPathElement>('.trunk-line')!;
const start = path.getPointAtLength(0).matrixTransform(path.getScreenCTM()!);
const tip = path.getPointAtLength(path.getTotalLength()).matrixTransform(path.getScreenCTM()!);
const first = document.querySelector('.branch-spine')!.getBoundingClientRect();
const last = [...document.querySelectorAll('.demo-node[aria-level="2"]')].at(-1)!.parentElement!.querySelector('.branch-spine')!.getBoundingClientRect();
await page.clock.runFor(920); // 2600ms: moving shared scene, persistent trunk AND panel onset.
const overlap = await page.evaluate(() => {
const anchor = document.querySelector<SVGGElement>('.trunk-anchor')!;
const matrix = anchor.getScreenCTM()!;
const start = new DOMPoint(0, 0).matrixTransform(matrix);
const tip = new DOMPoint(0, document.querySelector<SVGRectElement>('.trunk-reveal')!.height.baseVal.value).matrixTransform(matrix);
const first = document.querySelector<SVGSVGElement>('.branch-vector')!;
const last = [...document.querySelectorAll('.demo-node[aria-level="2"]')].at(-1)!.parentElement!.querySelector<SVGSVGElement>('.branch-vector')!;
const join = new DOMPoint(Number(last.dataset.cellWidth) / 2, Number(anchor.dataset.branchY) + Number(anchor.dataset.branchThickness) / 2).matrixTransform(last.getScreenCTM()!);
const firstTop = new DOMPoint(Number(first.dataset.cellWidth) / 2, 0).matrixTransform(first.getScreenCTM()!);
return {
start: Math.hypot(start.x - (first.left + first.width / 2), start.y - first.top),
end: Math.hypot(tip.x - (last.left + last.width / 2), tip.y - (last.top + last.height / 2)),
spineOpacity: Number(getComputedStyle(document.querySelector('.branch-spine')!).opacity),
trunkOpacity: Number(getComputedStyle(path).opacity),
start: Math.hypot(start.x - firstTop.x, start.y - firstTop.y),
end: Math.hypot(tip.x - join.x, tip.y - join.y),
spineOpacity: Number(getComputedStyle(first.querySelector('.branch-spine')!).opacity),
trunkOpacity: Number(getComputedStyle(document.querySelector('.trunk-line')!).opacity),
labelOpacity: Number(getComputedStyle(document.querySelector('.demo-node:not(.root-mark)')!).opacity),
panelOpacity: Number(getComputedStyle(document.querySelector('.text-panel')!).opacity),
sceneTransform: getComputedStyle(document.querySelector('.tree-scene')!).transform,
rowTransforms: [...document.querySelectorAll('.node-row, .demo-node')].map((row) => getComputedStyle(row).transform),
};
});
expect(handoff.start).toBeLessThan(1);
expect(handoff.end).toBeLessThan(1);
expect(handoff.spineOpacity).toBeGreaterThan(0);
expect(handoff.trunkOpacity).toBeGreaterThan(0);
expect(handoff.spineOpacity + handoff.trunkOpacity).toBeCloseTo(1, 4);
expect(handoff.labelOpacity).toBeGreaterThan(0);
expect(handoff.panelOpacity).toBeGreaterThan(0);
expect(overlap.start).toBeLessThan(1);
expect(overlap.end).toBeLessThan(1);
expect(overlap.spineOpacity).toBeGreaterThan(0);
expect(overlap.trunkOpacity).toBe(1); // No opacity handoff or replacement text spine.
expect(overlap.labelOpacity).toBeGreaterThan(0);
expect(overlap.panelOpacity).toBeGreaterThan(0);
expect(overlap.sceneTransform).not.toBe('none');
expect(overlap.rowTransforms.every((transform) => transform === 'none')).toBe(true);
const growingTreeRoot = (await root.boundingBox())!;
expect(growingTreeRoot.height).toBeGreaterThan(24.1);
await page.screenshot({ path: testInfo.outputPath('05-moving-tree.png') });
@@ -292,15 +365,17 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
for (const side of await panel.locator('.box-column').all()) await expect(side).toHaveText(/^[│\s]+$/);
}
await expect(page.locator('.branch-lines')).toHaveCount(0);
await expect(page.locator('.branch-prefix')).toHaveText([
expect(await page.locator('.branch-prefix').evaluateAll((prefixes) => prefixes.map((prefix) => prefix.getAttribute('data-prefix')))).toEqual([
'├── ', '│ ├── ', '│ └── ', '│ └── ',
'├── ', '│ └── ', '└── ', ' └── ',
]);
await expect(page.locator('.root-wire')).toHaveCount(0);
await expect(page.locator('.branch-spine')).toHaveText(['├', '│', '│', '│', '├', '│', '└', ' ']);
await expect(page.locator('.branch-prefix svg')).toHaveCount(8);
await expect(page.locator('.branch-spine path')).toHaveCount(3);
await expect(page.locator('.branch-arm path')).toHaveCount(8);
await expect(page.locator('.tree-scene')).toHaveCSS('transform', 'none');
const grid = await page.evaluate(() => {
const root = document.querySelector('.root-mark')!.getBoundingClientRect();
const spine = document.querySelector('.branch-spine')!.getBoundingClientRect();
const prefix = document.querySelector<HTMLElement>('.branch-prefix')!;
const cell = prefix.getBoundingClientRect().width / 4;
const frame = document.querySelector('.landing-content')!.getBoundingClientRect();
@@ -308,8 +383,8 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
const nodes = [...document.querySelectorAll<HTMLElement>('.demo-node:not(.root-mark)')].map((node) => node.getBoundingClientRect());
const foot = new DOMPoint(12, 60).matrixTransform(document.querySelector<SVGSVGElement>('.root-vector')!.getScreenCTM()!);
return {
rootColumnError: Math.abs(foot.x - spine.left - spine.width / 2),
rootGap: Math.abs(root.bottom - spine.top),
rootColumnError: Math.abs(foot.x - prefix.getBoundingClientRect().left - cell / 2),
rootGap: Math.abs(root.bottom - prefix.getBoundingClientRect().top),
frameCells: frame.width / cell,
rowHeights: rows.map((row) => row.height),
nodeColumns: nodes.map((node) => (node.left - frame.left) / cell),
@@ -336,13 +411,14 @@ for (const time of [1500, 2600]) {
await armed(page);
await page.keyboard.press('a');
await page.clock.runFor(time);
await expect(page.locator('.node-row').last()).not.toHaveCSS('transform', 'none');
await expect(page.locator('.tree-scene')).not.toHaveCSS('transform', 'none');
await page.keyboard.press('Escape');
await complete(page);
await expect(page.locator('.tree-scene')).toHaveCSS('transform', 'none');
for (const row of await page.locator('.node-row').all()) await expect(row).toHaveCSS('transform', 'none');
await expect(page.locator('.root-mark')).toHaveCSS('transform', 'none');
await expect(page.locator('.site-header')).toHaveCSS('opacity', '1');
await expect(page.locator('.branch-prefix').first()).toHaveText('├── ');
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├── ');
await expect(page.locator('.intro-art')).toBeHidden();
await page.clock.runFor(4000);
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete');
@@ -393,7 +469,7 @@ test('tree selection, collapse and call results are keyboard accessible', async
await expect(page.locator('.branch-spine').first()).toBeHidden();
await page.keyboard.press('ArrowRight');
await expect(page.locator('.branch-spine').first()).toBeVisible();
await expect(page.locator('.branch-prefix').first()).toHaveText('├── ');
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├── ');
await page.keyboard.press('End');
await expect(page.getByRole('treeitem', { name: 'protocol', exact: true })).toBeFocused();
await page.keyboard.press('Tab');
@@ -402,6 +478,139 @@ test('tree selection, collapse and call results are keyboard accessible', async
await expect(page.locator('[data-result]')).toContainText('fetch_interface');
});
test.describe('glyph trunk regressions', () => {
test.use({ deviceScaleFactor: 4 });
test('one SVG glyph representation persists through growth and completion', async ({ page }) => {
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
await armed(page);
const original = await page.locator('.trunk-line').elementHandle();
const outline = await page.locator('.trunk-line').getAttribute('d');
await page.keyboard.press('Enter');
let previous = 0;
for (const time of [1400, 2440, 2600, 2800, 4000]) {
await page.clock.runFor(time - previous);
await expect(page.locator('.trunk-line')).toHaveCount(1);
expect(await original!.evaluate((path) => path === document.querySelector('.trunk-line'))).toBe(true);
await expect(page.locator('.trunk-line')).toHaveAttribute('d', outline!);
await expect(page.locator('.trunk-line')).toHaveCSS('stroke', 'none');
await expect(page.locator('.root-vector .trunk-anchor .trunk-line')).toHaveCount(1);
await expect(page.locator('.trunk-line').locator('..')).toHaveAttribute('mask', 'url(#trunk-growth-mask)');
await expect(page.locator('.root-vector path')).toHaveCount(2); // Slash and trunk only.
await expect(page.locator('.branch-prefix text, .branch-prefix span, .root-wire, .branch-lines')).toHaveCount(0);
expect(await page.locator('.branch-prefix').evaluateAll((prefixes) => prefixes.every((prefix) => !prefix.textContent!.trim()))).toBe(true);
// First-column outlines are clipped to horizontal joins: no second spine.
const clips = await page.locator('.branch-vector clipPath rect').evaluateAll((rects) => rects.map((rect) => {
const svg = rect.closest('svg') as SVGSVGElement;
return { x: Number(rect.getAttribute('x')), width: Number(rect.getAttribute('width')), cell: Number(svg.dataset.cellWidth) };
}));
expect(clips.every(({ x, width, cell }) => Math.abs(x - cell / 2) < 1e-6 && Math.abs(width - cell / 2) < 1e-6)).toBe(true);
previous = time;
}
await complete(page);
});
test('collapse and re-expand keep the painted trunk aligned without excess spine', async ({ page }, testInfo) => {
await armed(page);
await page.keyboard.press('Escape');
await complete(page);
const checkEnd = async (name: string) => {
const trunk = await trunkGeometry(page);
const join = await page.evaluate(() => {
const anchor = document.querySelector<SVGGElement>('.trunk-anchor')!;
const last = [...document.querySelectorAll<HTMLElement>('.demo-node[aria-level="2"]')].filter((node) => !node.parentElement!.hidden).at(-1);
if (!last) return null;
const svg = last.parentElement!.querySelector<SVGSVGElement>('.branch-vector')!;
const point = new DOMPoint(Number(svg.dataset.cellWidth) / 2, Number(anchor.dataset.branchY) + Number(anchor.dataset.branchThickness) / 2).matrixTransform(svg.getScreenCTM()!);
return { x: point.x, y: point.y };
});
if (join) {
expect(Math.abs(trunk.x - join.x)).toBeLessThan(.1);
expect(Math.abs(trunk.bottom - join.y)).toBeLessThan(.1);
} else {
expect(trunk.length).toBe(0);
}
// Include the final painted pixel and the blank column underneath. This
// detects a path extending beyond its reveal mask or a spare text spine.
const clip = { x: Math.floor(trunk.x) - 2, y: Math.floor(trunk.bottom) - (join ? 2 : 0), width: 5, height: 28 };
// The root's keyboard focus ring crosses this column when collapsed;
// isolate tree ink without changing focus, geometry or glyph visibility.
const png = (await page.screenshot({ clip, path: testInfo.outputPath(`collapse-${name}.png`), style: '.root-mark:focus-visible { outline: none !important; }' })).toString('base64');
const pixels = await page.evaluate(async (png) => {
const image = new Image(); image.src = 'data:image/png;base64,' + png;
await image.decode();
const canvas = document.createElement('canvas');
canvas.width = image.width; canvas.height = image.height;
const ctx = canvas.getContext('2d')!; ctx.drawImage(image, 0, 0);
const data = ctx.getImageData(0, 0, image.width, image.height).data;
let count = 0, bottom = -Infinity;
for (let i = 0; i < data.length; i += 4) {
if (data[i] + data[i + 1] + data[i + 2] < 690) {
count++; bottom = Math.max(bottom, (Math.floor(i / 4 / image.width) + 1) / devicePixelRatio);
}
}
return { count, bottom };
}, png);
if (join) {
expect(pixels.count).toBeGreaterThan(0);
expect(Math.abs(clip.y + pixels.bottom - trunk.bottom)).toBeLessThan(.3);
} else {
expect(pixels.count).toBe(0);
}
return trunk.length;
};
let previous = await checkEnd('expanded');
for (const name of ['repository', 'issues']) {
const node = page.getByRole('treeitem', { name, exact: true });
await node.focus();
await page.keyboard.press('ArrowLeft');
await expect(node).toHaveAttribute('aria-expanded', 'false');
const length = await checkEnd(name);
expect(length).toBeLessThan(previous);
previous = length;
}
const knowledge = page.getByRole('treeitem', { name: 'knowledge', exact: true });
await knowledge.focus();
await page.keyboard.press('ArrowLeft');
expect(await checkEnd('knowledge')).toBe(previous); // Last child never extends the first-column spine.
await page.keyboard.press('Home');
await page.keyboard.press('ArrowLeft');
await expect(page.getByRole('treeitem')).toHaveCount(1);
await checkEnd('root');
await page.keyboard.press('ArrowRight');
expect(await checkEnd('root-reopened')).toBe(previous);
for (const name of ['repository', 'issues', 'knowledge']) {
await page.getByRole('treeitem', { name, exact: true }).focus();
await page.keyboard.press('ArrowRight');
}
expect(await checkEnd('all-reopened')).toBeGreaterThan(previous);
});
});
test.describe('JavaScript-free glyph tree', () => {
test.use({ javaScriptEnabled: false });
test('SSR paints one filled trunk and SVG branch outlines without animation code', async ({ page }, testInfo) => {
await page.goto('./');
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
await expect(page.locator('.no-script-note')).toBeVisible();
await expect(page.locator('.trunk-line')).toHaveCount(1);
await expect(page.locator('.trunk-line')).toHaveCSS('opacity', '1');
await expect(page.locator('.trunk-line')).toHaveCSS('stroke', 'none');
await expect(page.locator('.branch-prefix svg')).toHaveCount(8);
expect(await page.locator('.branch-prefix').evaluateAll((prefixes) => prefixes.every((prefix) => !prefix.textContent!.trim()))).toBe(true);
const trunk = await trunkGeometry(page);
expect(trunk.width).toBeCloseTo(1.064, 5);
expect(trunk.length).toBeGreaterThan(0);
expect(Math.abs(trunk.axis)).toBeLessThan(.1);
expect(trunk.gapRatio).toBeCloseTo(1 / 14, 5);
await page.locator('.demo-tree').screenshot({ path: testInfo.outputPath('no-js-glyph-tree.png') });
await page.getByRole('link', { name: 'Book', exact: true }).click();
await expect(page.getByRole('heading', { level: 1 })).toContainText('通信の約束まで');
});
});
test('reduced motion opens the working demo immediately', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('./');
+8 -5
View File
@@ -6,14 +6,17 @@ test('home and book are usable without client JavaScript', async ({ page }, test
await page.goto('./');
await expect(page.getByRole('heading', { level: 1 })).toContainText('探索できる世界へ');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await expect(page.locator('.branch-prefix').first()).toHaveText('├── ');
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├── ');
const root = await page.locator('.root-mark').boundingBox();
const spine = await page.locator('.branch-spine').first().boundingBox();
const spine = await page.locator('.branch-vector').first().evaluate((element) => {
const svg = element as SVGSVGElement;
const top = new DOMPoint(Number(svg.dataset.cellWidth) / 2, 0).matrixTransform(svg.getScreenCTM()!);
return { x: top.x, y: top.y };
});
expect(root).not.toBeNull();
expect(spine).not.toBeNull();
const footX = await page.locator('.root-vector').evaluate((svg) => new DOMPoint(12, 60).matrixTransform((svg as SVGSVGElement).getScreenCTM()!).x);
expect(Math.abs(footX - spine!.x - spine!.width / 2)).toBeLessThan(1);
expect(Math.abs(root!.y + root!.height - spine!.y)).toBeLessThan(1);
expect(Math.abs(footX - spine.x)).toBeLessThan(1);
expect(Math.abs(root!.y + root!.height - spine.y)).toBeLessThan(1);
await page.screenshot({ path: testInfo.outputPath('home.png'), fullPage: true });
await page.getByRole('link', { name: 'Book', exact: true }).click();
await expect(page.getByRole('heading', { level: 1 })).toContainText('通信の約束まで');
+50
View File
@@ -0,0 +1,50 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import opentype from 'opentype.js';
import { glyphPath, trunkPath, cellWidth, rowHeight, stemWidth, branchY, branchThickness } from '../src/lib/landing-glyphs.mjs';
const box = (d) => opentype.Path.fromSVG(d, { flipY: false, decimalPlaces: 5 }).getBoundingBox();
const close = (actual, expected) => assert.ok(Math.abs(actual - expected) < .00002, `${actual} != ${expected}`);
test('pinned font binaries and upstream licenses match their integrity manifest', () => {
const lines = readFileSync('public/fonts/SHA256SUMS', 'utf8').trim().split('\n');
assert.ok(lines.length >= 8);
for (const line of lines) {
const [expected, name] = line.trim().split(/\s+/);
const bytes = readFileSync(`public/fonts/${name}`);
assert.equal(createHash('sha256').update(bytes).digest('hex'), expected, name);
}
});
test('Lyth Mono paths retain exact advance, row bounds and stroke dimensions', () => {
close(cellWidth, 8.456);
close(stemWidth, 1.064);
close(branchY, rowHeight / 2);
const vertical = box(glyphPath('│'));
close(vertical.y1, 0);
close(vertical.y2, 24);
close((vertical.x1 + vertical.x2) / 2, cellWidth / 2);
close(vertical.x2 - vertical.x1, stemWidth);
const horizontal = box(glyphPath('─'));
close(horizontal.x1, 0);
close(horizontal.x2, cellWidth);
close(horizontal.y2 - horizontal.y1, branchThickness);
const trunk = box(trunkPath);
close(trunk.x1 + trunk.x2, 0);
close(trunk.x2 - trunk.x1, stemWidth);
close(trunk.y1, 0);
close(trunk.y2, 1);
});
test('cell layout bypasses shaping, preserves spaces and fails for missing glyphs', () => {
close(box(glyphPath(' │')).x1, box(glyphPath('│')).x1 + 4 * cellWidth);
close(box(glyphPath('── ')).x2, 2 * cellWidth);
for (const prefix of ['├── ', '│ ├── ', '│ └── ', '│ └── ', ' └── ']) {
const path = glyphPath(prefix);
assert.ok(path.length > 0);
assert.ok(!path.includes('NaN'));
}
assert.throws(() => glyphPath('日'), /Lyth Mono is missing/);
});