fix: unify landing glyph animation with pinned site fonts
Reference site / build (push) Canceled after 0s
Reference site / build (push) Canceled after 0s
This commit is contained in:
Generated
+42
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
@@ -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.
@@ -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.
@@ -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.
|
||||
@@ -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
|
||||
@@ -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,12 +53,17 @@ 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>
|
||||
{root && <span class="root-caption" data-reveal>worldspace</span>}
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="ascii-panel text-panel" aria-labelledby="text-view-title" data-reveal>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
@@ -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('./');
|
||||
|
||||
@@ -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('通信の約束まで');
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user