nzw.jp Design System

サイト全体で共有するデザイントークンの単一の真実源。すべて /static/css/design-system.css の CSS 変数(--nzw-*)として定義されています。変数名やコードをクリックするとコピーできます。

使い方

各ページの <head> で CSS を読み込み、プロパティ値に変数を参照します。

<link rel="stylesheet" href="https://nzw.jp/static/css/design-system.css">

.my-title  { color: var(--nzw-text-strong); font-size: var(--nzw-text-2xl); }
.my-button { background: var(--nzw-accent); border-radius: var(--nzw-radius-full); }
.my-card   { box-shadow: var(--nzw-shadow-sm); padding: var(--nzw-space-6); }

配色 — Color

基準は index.html。アクセント=Sky、テキスト=Slate 系。

テキスト色の使い分け(WCAG コントラスト)
  • 白背景の本文には --nzw-text(14.6:1) / --nzw-text-strong / --nzw-sub(4.8:1) / --nzw-blue / --nzw-accent-deep(5.9:1) を使用。
  • --nzw-accent(2.8:1)・--nzw-muted(2.6:1)・--nzw-warning(2.2:1) はテキスト不可。塗り・枠線・アイコン・大きな装飾のみ。リンク/本文のアクセント文字は --nzw-accent-deep を使う。
  • アクセント面に白文字を載せるボタンは --nzw-accent-strong 以上(4.1:1)を背景に使う(.nzw-btn--primary は対応済み)。

Surface / Border

Text

Accent (Sky)

Secondary / Semantic

Brand accents(プロダクト別)

タイポグラフィ — Typography

和文 Noto Sans JP + 欧文 Inter。等幅 JetBrains Mono。表示用 Orbitron / ゲーム Rajdhani。

Sans — Noto Sans JP / Inter あア亜 AaBb 0123
Mono — JetBrains Mono const x = 0;
Display — ORBITRON 0123
Game — RAJDHANI SCORE 9999

サイズスケール

余白 — Spacing

4px グリッド。--nzw-space-1 = 4px 〜 --nzw-space-20 = 80px。

スマホの版組 — Mobile layout

狭い画面で崩れるのは、たいてい 余白・表の列幅・改行の位置 の 3 つです。ただし「スマホだから小さくする」は解決にならない(一度やって戻しました)。直すのは構造のほうで、余白は広い画面と同じまま保ちます。基準にしている数字は iPhone の論理幅 390px と 430px の実測です。

折返し点は 3 つだけ

@media の条件にカスタムプロパティは使えない(メディアクエリは変数を評価しない)ので、数字はどうしても各ページに直書きになります。だからこそ書いてよい数字を決めておく。増やす前に、この 3 つで足りないかを先に疑ってください。

変数値そこで何を変えるか
--nzw-bp-sm640px表を畳み、ラベルと操作を 2 列グリッドで対にする。このページの直しはほぼ全部ここ。余白と級数はここで変えない
--nzw-bp-md720px追従する目次を 1 行の横流しにする。折り返すと項目数ぶん背が伸びて、追従ヘッダーが画面を食う
--nzw-bp-lg820px2 段組を 1 段に落とす(カードの解剖図など)

余白は画面幅で落とさない

狭いから削る、をやめた(2026-09-24)

最初は 640px 以下でページの左右とカードの内側を 24px → 16px、節の間を 64px → 40px、 見出しの級数まで 1 段ずつ落としました。本文に回る幅は 294px → 326px に増えます。 それでも読みやすくはなりませんでした。窮屈さだけが増えて、直したかった 3 つとは関係が無かったからです。

狭い画面で幅が足りなかった本当の原因は、畳めない表と割れないインラインコードでした。 そちらを構造から直したら、294px のままで足ります。余白は 24px を崩さない。 この 2 つのトークンは「段を落とすため」ではなく、ページの左右とカードの内側を 1 か所で決めるために置いてあります。

変数値使うところ
--nzw-pad-page24pxページの左右。ヘッダー・目次・本文・群の帯を同じ 1 本に揃える
--nzw-pad-card24pxカード・パネルの内側
--nzw-gap-section64px節と節のあいだ
例外は「左に何かを持っている行」だけ

.ds-steps の 1 行は左端に番号の板(26px + 間隔 16px)を持っていて、それ自体が余白の役をしています。 ここに 24px を足すと、390px の画面で本文の列が 299px → 227px まで痩せ、 等幅のチップが 1 行に入らなくなって改行が増えました。余白を足したのに読みにくくなる、という形です。 この行だけ 16px にしています。余白は「同じ数字を配る」ではなく「残る列の幅」で確かめること。

リストの字下げ消しが、カードの内側余白まで消す

ol.ds-steps { padding: 0 } はリストの字下げ(既定の padding-inline-start: 40px)を 消すつもりの 1 行でした。ところが要素+クラスの指定は .ds-panel より強いので、 <ol class="ds-panel ds-steps"> ではカードの内側余白まで一緒に消えます。 本文が枠線に触れていた原因はこれで、広い画面でも同じように触れていました。

区切り罫はカードの端まで通したいので、余白は li 側に持たせ、カード側は上下だけにしています。 一括指定の打ち消しは、消したい 1 辺だけを狙うこと。

表は「積む」が既定、「流す」が例外

狭い画面で表を width: 100% のまま押し込むと、いちばん長い列が 1 文字ずつ縦に流れます。幅が足りないときに削られるのは、いつも「説明」の列です。

  1. 1
    既定は積む — .ds-table そのまま

    640px 以下で 1 行を 1 かたまりに畳み、見出し行(thead)は隠します。1 行目に「見出しセルと値」、2 行目に説明。列の見分けは太字・等幅・灰色という書体側に移します。既定パラメータの表はこれで 1 画面に 4 行 → 10 行になりました。

  2. 2
    例外は流す — .ds-table--wide

    列を左右に並べて見比べること自体に意味がある表だけ。畳むと比較できなくなるからです。器には .nzw-scroll-x を付けて端にぼかしを出します。このページでは時間帯テーマの値・天気プリセット・ブラックホールの大きさの 3 つだけです。

  3. 3
    畳むと意味が消える列には data-label

    <td data-label="彩度"> と書くと、積んだときだけ小さな見出しが値の前に出ます。「—」や数字だけの列は、見出し行が消えると何の値か分からなくなるので、そこにだけ付けます。書体で見分けがつく表には付けません。

流す表の左右 padding は、器ではなく端の列が持つ

器に padding を残したまま横に流すと、右端まで送ったときだけ余白が消えて文字が枠線に触れます (overflow は padding ボックスの外側で切られるため)。 器の左右を 0 にして、th:first-child / td:last-child に --nzw-pad-card を移します。 表が収まる広い画面では見た目は変わりません。

改行の位置

インラインコードは置換要素として扱う

--nzw-accent-deep のようなトークン名は、既定だとハイフンのところで切れて、 行末に -- だけのチップが残ります。word-break: keep-all はハイフンには効きません(実測)。 止められるのは white-space: nowrap だけです。

ただし nowrap だけでは足りません。<code>--nzw-muted</code>(2.6:1)・<code>--nzw-warning</code> のように チップ・括弧・中黒が続くと、ハイフンの前と開き括弧の前は改行禁止(UAX#14 の LB21 / LB30)なので、 全体がひと続きの「1 語」になって 467px まで伸びます。display: inline-block にすると チップが 1 個の置換要素として扱われ、その前後に改行の機会ができます。実測で横あふれ 99px → 0px。

1 行に入らないほど長いもの(おおよそ 45 字。390px の端末で本文 326px、等幅 11.9px の送りが 7.1px)は .ds-code--wrap で折り返しに戻します。付け忘れても版面が押し広がらないよう、 読み込み後に実測して付け直すスクリプトを置いています。

対象指定ねらい
インラインコードdisplay: inline-block
white-space: nowrap
チップを 1 個の置換要素にして、中では割らせず、前後に改行の機会を作る。どのブラウザでも効く
見出しtext-wrap: balance「(WCAG コントラス/ト)」のように1 文字だけ次の行に落ちるのを防ぐ。行数の少ない塊にだけ使う。Chrome 114+ / Safari 17.5+ / Firefox 121+ なので、iPhone にも届く
和文の本文word-break: auto-phrase
line-break: strict
狭い列でいちばん読みにくいのは文節の途中で切れること。ただし auto-phrase は Chrome 119+ だけで、iOS Safari は 27 時点でも未対応(Technology Preview のみ)。iPhone での改善は文節ではなく「幅が 32px 広がったこと」のほうから来ていると思っておくのが正しい。未対応でも既定のまま落ちるので、入れておいて損はない
本文text-wrap: pretty最終行のぶら下がりだけを直す。これも効かないブラウザでは何も起きない
等幅word-break: normal「語」の形が意味を持つので、文節の判定を掛けない

ラベルと操作は必ず対にする

折り返す flex では、ラベルだけが前の行に取り残される

weather[晴 曇 雨 雪]stage[昼 夜]を flex-wrap で並べると、 狭い画面では stage が 1 行目の末尾に残り、[昼 夜]だけが 2 行目に落ちます。 ラベルと、それが指す操作が別の行に分かれるのがいちばん困る壊れ方です。

grid-template-columns: max-content minmax(0, 1fr) の 2 列にすれば、 ラベルと操作が必ず同じ行で対になります。マークアップは触りません。 背景の線とブラックホールの見本で実際に効いています。

現在地の下線 — Current underline

「いまここ」を線で示すときの寸法。ヘッダーのナビがこれを使っています。

文字のすぐ下に引かない

線を文字の直下(0px)に置くと、「グ」「ノ」のような下に伸びる字が線に触れて窮屈に見えます。 --nzw-underline-offset(4px)空けるのが既定です。 この値は「行ボックスの下端」からの距離で、字の下端からではありません。 13px の文字だと行ボックスの底は字の下端より 2〜3px 下にあるので、見た目の空きは 6〜7px になります。 8px にしたら離れすぎました。

太さは --nzw-underline-weight(2px)。1px にしないこと。 紙スキン(#F5F3EE の地)では 1px の線が罫線と区別できず、現在地に見えません。

余白の入れ方

上下そろえて入れ、全項目に付ける

下線は箱の下端に引かれるので、余白は padding で作ります。このとき 2 つ守ること。

上下そろえて入れる。align-items: center の並びで下だけ広げると、 文字がロゴやボタンに対して浮き上がります。
現在地の項目「だけ」に付けない。項目ごとに箱の高さが変わり、並びの重心がずれます。 下線が出るのは 1 つでも、余白は全項目が持ちます。

線の長さ

中身より少しだけ外へ出す

文字の幅と線の幅をぴったり同じにしないこと。「測っている」ように見えて落ち着きません。 左右へ --nzw-underline-bleed(4px)ずつはみ出させて、「支えている」線にします。 下げ幅と同じ値なので、「中身より 4px 外側から 4px 外側まで、4px 下」の 1 本のルールで覚えられます。

このため線は ::after で描きます。box-shadow も background も 箱の「中」にしか描けないので、外へはみ出せません。

箱の中の「見えない余白」は引く

箱の中に区切り罫やその余白が入っているときは、そのぶんを線の始まりから引きます。 ヘッダーでは群の先頭の項目が箱の左端に縦罫を持っているので、 縦罫 1px + その margin 12px + 箱の gap 6px = 19px を引いています。 gap を引き忘れると線だけが 6px はみ出し、アイコンの左から線が生えて見えます(実際にそうなりました)。

余白なし(窮屈) 4px 空けて 4px はみ出す(既定)

--nzw-underline-offset = var(--nzw-space-1)(4px)/ --nzw-underline-bleed = var(--nzw-space-1)(4px)/ --nzw-underline-weight = 2px。実装は /static/css/site-nav.css の .site-header-link と [aria-current="page"]::after。

角丸 — Radius

影 — Shadow

グラデーション — Gradient

Rainbow line
--nzw-rainbow
Accent
--nzw-gradient-accent
Surface
--nzw-gradient-surface

モーション — Motion

標準トランジション --nzw-transition(300ms)。共通キーフレーム nzw-float / nzw-spin-slow / nzw-rainbow-flow。

nzw-float
nzw-spin-slow
nzw-rainbow-flow

虹ライン — Rainbow line

ヘッダー下とフッター上に敷く .nzw-rainbow-line の候補比較。実サイトと同じ 2px・同じ周期・同じ天気倍率で動かしています。

彩度と「虹に見えること」はトレードオフ

明度を上げて白へ寄せるほど、隣り合う色相どうしの RGB 差が縮まって単なる薄いグレー線になります。 高さ 2px の細線ではこの効果が特に強く、明度 93% を超えたあたりで虹として判別できなくなります。
やわらかさを保ったまま虹を残すには、彩度をむやみに下げるのではなく明度をパステル域(86〜90%)に置くのが有効です。

候補

A彩度を下げきった案hsl(h, 14%, 94%)

隣り合う色相の差が数段階しかなく、線が動いていることも判別できなくなったため不採用。

雪 ×0.5 / 110s
曇 ×0.75 / 90s
雨 ×1.0 / 72s
晴 ×1.3 / 56s
Bごく淡いパステルhsl(h, 30%, 92%)

虹とわかる下限。白背景の 2px ではこれ以上落とすと色が消える。

雪 ×0.5 / 110s
曇 ×0.75 / 90s
雨 ×1.0 / 72s
晴 ×1.3 / 56s
C淡いパステル採用hsl(h, 42%, 90%)

採用値。--nzw-rainbow はこの 7 色。やわらかさと視認性の折り合いが最も良い。

雪 ×0.5 / 110s
曇 ×0.75 / 90s
雨 ×1.0 / 72s
晴 ×1.3 / 56s
D標準パステルhsl(h, 55%, 88%)

一段はっきりする。まだ上品な範囲。

雪 ×0.5 / 110s
曇 ×0.75 / 90s
雨 ×1.0 / 72s
晴 ×1.3 / 56s
E1 回目の調整値hsl(h, 40%, 86%)

最初にトーンダウンしたときの値。C より明度が低いぶん重い。

雪 ×0.5 / 110s
曇 ×0.75 / 90s
雨 ×1.0 / 72s
晴 ×1.3 / 56s
F変更前(元のサイト)hsl(h, 100%, 85%)

純パステル 7 色。指摘のあったギラギラの一因。

雪 ×0.5 / 110s
曇 ×0.75 / 90s
雨 ×1.0 / 72s
晴 ×1.3 / 56s

天気連動のスケール

雪 → 曇 → 雨 → 晴 の順に、彩度と速度が段階的に上がります。判定範囲は bg-synapse.js の WEATHER_PRESETS と共通。彩度は 3 秒かけて滑らかに変化し、周期の切り替えは位相を保持するため見た目のジャンプは起きません。大雨だけはこの 1 軸から外れ、周期は晴れ寄りに速く・彩度は雨より低くします(動きは強いが色は濁る、という別の性格づけ)。

body クラス彩度倍率周期天気コード(Open-Meteo)
.weather-snow×0.5110s71–77 / 85–86
.weather-cloudy×0.7590s2 / 3 / 45 / 48
.weather-rain×1.072s51–67 / 80–82
.weather-rain-heavy×0.8260s65 / 67 / 82 / 95–99(.weather-rain に足す)
.weather-clear×1.356s0 / 1
(既定値)×1.090s上記以外

時間帯テーマ — Time of day

トップページは body に時間帯クラスを 1 枚だけ付けて配色を切り替えます。4 種類ありますが、地と文字が入れ替わるのは theme-night だけです。

「夜だけ反転する」という決め

朝・夕は白地に淡い色を重ねただけの「白の変奏」に留めてあります。こうしておくと本文・罫線・カードの 色をそのまま使い回せて、テーマごとの上書きが要りません。反転が要るのは夜の 1 テーマだけなので、 body.theme-night ... の上書きもそこに閉じ込められます。

4 テーマそれぞれに専用の配色を持たせる案は取りませんでした。カードが 30 枚以上あってブランド色も バラバラなので、4 通りに増やすと上書きが 4 倍に増えるうえ、時間帯ごとに違うサイトに見えてしまいます。 「時間帯は空の色、プロダクトの色は変えない」という切り分けにしています。

4 つのテーマ

Morning
本文とカードは昼と共通。
空の色だけが淡く水色に寄る。
Card
.theme-morning05:00 – 10:00 / 選択色 #fb923c
Day
基準になる状態。
デザイントークンの既定値そのもの。
Card
.theme-day10:00 – 16:00 / 選択色 #eab308
Sunset
こちらも白の変奏。
文字だけ焦茶に寄せてなじませる。
Card
.theme-sunset16:00 – 19:00 / 選択色 #ea580c
Night
唯一の反転。カード・ヘッダー・
フッター・文字色を上書きする。
Card
.theme-night19:00 – 05:00 / 選択色 #6366f1

値

クラスAUTO の時間帯body 背景本文色アイコン
.theme-morning05 – 10 時linear-gradient(to bottom, #e0f7fa, #ffffff)#006064fa-cloud-sun
.theme-day10 – 16 時#ffffff#1e293bfa-sun
.theme-sunset16 – 19 時linear-gradient(to bottom, #fff3e0, #ffe0b2)#4e342efa-mountain-sun
.theme-night19 – 05 時#0f172a#f1f5f9fa-moon

切り替えと保存

選び方ヘッダーの表示設定メニュー。Theme (Time) は auto / morning / day / sunset / night の 5 択、Effect (Weather) は auto / sunny / cloudy / rain / snow の 5 択。
保存localStorage の timeMode / weatherMode。既定はどちらも auto。
AUTO の中身時間帯は端末時刻(上の表の区切り)。天気は Open-Meteo の current_weather を東京の座標で 1 回だけ取得してキャッシュする。取得に失敗したら晴れ扱いにフォールバックする。
RANDOM時間帯と天気を同時に引き直す。auto ではなく具体値として保存するので、押したあとは端末時刻に戻らない。
遷移body { transition: background-color 1.5s ease, color 1.5s ease; }。切り替わりを 1.5 秒かけるのは、時計をまたいだ瞬間に画面がパチンと変わらないようにするため。

夜モードで上書きするもの

反転で読めなくなる面と文字だけを、狙って上書きします。原則は「面は slate 系に置き換え、ブランドの色は枠線に残す」。

対象夜の値ねらい
.site-header--glassrgba(15, 23, 42, .85) / 罫線 rgba(255,255,255,.1)すりガラスの地を暗くする。共通ヘッダーのスキン変数(--sh-*)を差し替える
.card-hoverrgba(30, 41, 59, .6)カードの地
footerrgba(15, 23, 42, .9)同上
.text-slate-800#f1f5f9見出し
.text-slate-600#e2e8f0本文
.text-slate-500#94a3b8補助テキスト
.premium-bg-*linear-gradient(145deg, rgba(30,41,59,.7), rgba(15,23,42,.8))カードの面は全ブランド共通の 1 枚に寄せ、border-color だけブランド色(α 0.3)を残す
.premium-btn-*ブランド色 α 0.2 の地 + 明るい文字昼は淡色ベタ、夜は透過。ボタンだけ浮かないように

時間帯と天気が効く先

配色以外にも波及します。ここが「時間帯 / 天気」という 2 軸で画面が変わる全体像です。

効く先時間帯天気
body の配色4 テーマ(上表)—
虹ライン—彩度 ×0.5 – ×1.3、周期 110s – 56s(虹ライン)
背景の線—色相・速度・形状・結線距離のプリセット(背景の線)
背景の粒地の明暗(setTheme)速度・大きさ・色相のプリセット。雨は「大雨・雷雨」が独立して1段増える(背景の粒)
降るもの—カード版・6 幕には降らせない(読み物の前面に粒を置かない)。雨・雪を絵として持つのは窓辺だけで、.qt-motes の形・速さ・本数で表す(雨は細い縦線、大雨は 1.9 倍の長さで本数 24 → 60)

背景の線 — Synapse background

トップページの全面に敷いた #bg-canvas。神経の伝播を模した、ノードと線のアニメーションです。実装は /static/js/bg-synapse.js(SynapseBackground クラス)。

実際は「軽い」と「濃い」を交互に見せている

ドメイン直下(トップページ)で動いているのは、ここで見せる軽い(Canvas 2D・点を線でつなぐ)と、 もう1つの濃い(背景の粒、WebGL2・漂う粒+稲妻)のどちらか一方です。 選ぶのは /static/js/bg-manager.js。端末が「濃い」に耐えるかを見て、耐える端末では最初の1枚をくじで決め、 以後10秒ごとに軽い/濃いを入れ替えます(濃いが落ちたときはその回だけ軽いに差し替え、次の番でまた濃いを試す)。

動きを減らす設定(prefers-reduced-motion)のときはどちらも動かしません(off)。 ヘッダーの歯車メニューから「軽い・濃い・切」を手で選ぶと、以後は自動判定より優先されます。

実装そのまま。ノード数と結線距離だけ、密度が実サイトと同じになるよう縮めています
weather stage

白地では意図的にほとんど見えません(本文より目立たせないため)。線の形と伝播の順番を確かめたいときは 夜に切り替えてください。1 サイクルは 発火 → フェード → 休憩 なので、 何も光らない時間が数秒あります。

何を描いているか

  1. 1
    ノードを撒く

    画面端 margin: 50px の内側に particleCount: 45 個。半径 1–3px。ごく遅いブラウン運動で漂い、速度は目標値の 0.5–1.5 倍に収束させ続ける(速すぎても遅すぎても補正が入る)。壁では反射する。

  2. 2
    近いもの同士を結ぶ

    距離が connectionDistance: 250px 未満のノードを双方向に結線。ただし Math.random() > 0.1 の判定で約 1 割は間引く。全結合にすると総当たりの網になって「たまたま繋がっている」感じが消えるため。毎フレーム 2% の確率で結線を作り直し、動きに追従させる。

  3. 3
    1 点から伝播させる

    ランダムな 1 ノードを起点に幅優先探索(BFS)で到達順を作り、nodeActivationDelay: 150ms ずつずらして順に発火させる。発火したノードは activationTime = 1.0 から毎フレーム 0.008 ずつ減っていく。両端が発火中の線だけが色と shadowBlur を持ち、それ以外は rgba(180,180,180, …) の細い線として残る。

1 サイクルの流れ

PROPAGATING45 × 150ms ≒ 6.8s起点から順に発火。色相はサイクル開始時に baseHue + rand × hueVariation で決め直す
fadeOutTime3,000ms最後のノードが消えるのを待つ
RESTING5,000ms何も光らない時間。ここが無いと画面が常に点滅して読む邪魔になる
次のサイクル色相を引き直す起点も毎回ランダム。同じ流れは二度起きない

既定のパラメータ

キー既定値効果
particleCount45ノード数
connectionDistance250この距離(px)以内を結線
baseHue / hueVariation0 / 60発火色の基準色相と、1 サイクルごとのばらつき
saturation / lightness38% / 74%HSL の彩度・明度。虹ラインと同じく、白へ寄せすぎると色が消える
speedMultiplier0.6移動速度の係数
nodeActivationDelay150ノード間の発火遅延(ms)。実質の伝播速度
lineWidth / shadowBlur1.5 / 4発火中の線の太さとグロー量
fadeOutTime / restTime3000 / 5000フェード待ちと休憩(ms)
maxConnectionsRebuildChance0.02毎フレームで結線を組み直す確率
idleColorrgba(180, 180, 180, 0.2)発火していないノードの色

天気プリセット

天気コード(Open-Meteo)で setWeather(code) を呼ぶと、この表の値が現在設定にマージされます。判定範囲は虹ラインと共通です。

コード色相形状彩度 / 明度速度結線距離発火間隔
晴0 / 130 ±40circle40% / 72%0.8280120ms
曇2 / 3 / 45 / 48210 ±20circle20% / 70%0.4250180ms
雨51–67 / 80–82200 ±40ripple42% / 70%0.8300100ms
雪71–77 / 85–86190 ±20snowflake24% / 85%0.25180200ms

雪だけ opacityFactor: 0.9 / lineOpacityFactor: 0.7 を足して、結晶より線が目立たないようにしています。 未知のコードのときは nodeActivationDelay だけ既定に戻し、他は直前のプリセットを引き継ぎます。

3 つの形状

shape描き方
circle半径 1–3px の塗り。発火中は shadowBlur × 1.5 のグローが付く。既定。
snowflake6 本の主枝+先端の小枝+中心の六角形を線で描く。毎フレーム 0.02rad 回転する。発火中のノードだけ結晶になり、消えている間は点のまま。
ripple中心の点+同心円 3 重。外側ほど細く薄い(lineWidth = 2 - i × 0.3、alpha = 0.4 - i × 0.1)。

ページへの敷き方

<canvas id="bg-canvas"></canvas>
<script src="/static/js/bg-synapse.js"></script>
<script>
  const bg = new SynapseBackground(document.getElementById('bg-canvas'));
  if (!window.PREFERS_REDUCED_MOTION) bg.start();
  bg.setWeather(0);   // 天気コードでプリセット切替
</script>

/* トップページでの見え方。線そのものは主張させない */
#bg-canvas {
  position: fixed; inset: 0;
  z-index: -1;           /* body の背景より上・コンテンツより下 */
  pointer-events: none;
  filter: blur(0.3px);   /* セクション背景の上を流れるときの硬さを取る */
  opacity: 0.55;
}

opacity: 0.55 と blur(0.3px) は後から足したものです。素のままだと線がくっきりしすぎて、 カードの上を横切ったときに本文より目立ちました。動きを減らす設定(prefers-reduced-motion)の ときは start() 自体を呼びません。

背景の粒 — Field background

上の背景の線と交互に出る、もう1つの段階(濃い)。漂う粒と、ときどき走る稲妻です。実装は /static/js/bg-field.js(BackgroundField クラス)。素の WebGL2 で、外部ライブラリは使っていません。

実装そのまま。粒の数だけ、密度が実サイトと同じになるよう縮めています
weather stage

稲妻は4〜8秒に1回、ランダムな間隔で走ります(大雨・雷雨では間隔が詰まります)。しばらく見ていると、 粒から粒へビリヤードのように跳ねる折れ線が見えるはずです — スクリーンショットで「星座」に見えるのはこれです。

何を描いているか

  1. 1
    地の色

    全面を覆う三角形1枚に、2オクターブのノイズで tintA ↔ tintB をゆっくり混ぜる(アニメGIFのような繰り返しではなく、time uniform で連続的に流す)。不透明度は明るい地で0.16、暗い地で0.30しかない。「見える」より「気配」を狙う層で、ブラックホールの重力圏の輪と同じ考え方。

  2. 2
    粒を漂わせる

    pointCount: 1800個の点を、CPU側のシミュレーションなしで動かす。各点の位置は (種, 位相, 時刻) だけで決まる閉じた式(サイン・コサインで小さな楕円をなぞる)なので、頂点シェーダだけで完結する。位相の大小で奥行き(サイズと不透明度)を表現し、遠近感を出す。

  3. 3
    稲妻を走らせる

    4〜8秒に1回、ランダムな1粒を起点に、進行方向 ±55° の円錐内から「狙いの跳躍距離にいちばん近い粒」を選んで5〜8回跳ねる(ビリヤードのように、当たるたびに向きを振る)。各辺をギザギザに4分割して折れ線にし、当たった粒には閃光を置く。跳ねが3回未満で終わったら「ただの線」にしか見えないため捨てて、次の周期に引き直す。

既定のパラメータ

キー既定値効果
pointCount1800粒の数
dpr1.6解像度の上限。実機の devicePixelRatio がこれより大きくても頭打ちにする
speed1.0漂う速さの係数。稲妻の間隔にも効く(速いほど間隔が詰まる)
size22粒の基準サイズ(px)。位相で0.35〜1.0倍される
color[0.42, 0.58, 0.78]粒の色(RGB 0–1)。天気プリセットで差し替わる
pointAlpha / sizeScale0.45 / 0.7粒の不透明度と大きさの係数。setTheme() が地の明暗に合わせて上書きする
tintA / tintB明るい地の2色地の色のグラデーション両端。setTheme() が時間帯テーマに合わせて差し替える
boltAlpha0.28稲妻の濃さ。0にすると draw call ごと出さない

天気プリセット

setWeather(code) で速度・大きさ・色相を差し替えます。判定範囲は虹ラインや背景の線とほぼ共通ですが、こちらだけ「大雨・雷雨」が独立した1段濃い区分として増えています(65 / 67 / 82 / 95–99)。判定は「大雨・雷雨」を「雨」より先に見る — コード65・67・82は雨の範囲(51–67 / 80–82)にも入るため、あとに置くと大雨側の判定に届かない。

コード速度粒の大きさ色相(RGB)
晴0 / 11.25261.00, 0.86, 0.72
曇2 / 3 / 45 / 480.80220.80, 0.84, 0.90
大雨・雷雨65 / 67 / 82 / 95–991.60140.55, 0.70, 0.88
雨51–67 / 80–821.05180.68, 0.82, 0.95
雪71–77 / 85 / 860.55300.92, 0.96, 1.00

地の明暗に合わせる

setTheme(dark, tintA, tintB) を時間帯テーマの切り替えに合わせて呼ぶ(背景の線と同じ橋渡しを bg-manager.js が持つ)。明るい地では粒を弱く小さくする — 暗い地と同じ量を白地に乗せるとベージュの斑点になって本文が読みにくくなるため。稲妻も明るい地では通常合成、暗い地では加算合成(光として足す。通常合成だと線が粒を消してしまう)に切り替える。

pointAlphasizeScaleboltAlpha稲妻の合成
明るい地0.450.70.28通常(SRC_ALPHA, ONE_MINUS_SRC_ALPHA)
暗い地1.01.00.62加算(SRC_ALPHA, ONE)

壊れたら「軽い」へ

WebGL2 はソフトウェア実装や低スペック端末で Canvas 2D より遅いことがあるため、動かす価値が無いと判断したら呼び出し側(bg-manager.js)に軽いへ落とすよう伝えます。

条件挙動
WebGL2が取れない/ソフトウェアレンダラstart() が false を返す。bg-manager.js はその場で軽いを起動し、以後は濃いを試すこと自体をやめる(毎回同じところで落ちるだけのため)
webglcontextlost が2回連続onFallback('context-lost') を呼ぶ。モバイルSafariは背面から戻ると普通にコンテキストを失うため、1回だけでは落とさない
90フレーム連続でフレーム時間24ms超onFallback('slow') を呼ぶ。起動直後120フレーム(シェーダのコンパイル・初回ペイントが集中する)は見ない。この回だけ軽いに差し替わり、次の10秒周期でまた濃いを試す

ページへの敷き方

<script>
  // bg-manager.js は「濃い」に耐える端末と判定したときだけ、これを遅延読み込みする
  const s = document.createElement('script');
  s.src = '/static/js/bg-field.js';
  s.onload = () => {
    const bg = new BackgroundField(document.getElementById('bg-canvas'));
    if (!bg.start()) return;   // WebGL2 なし・ソフトウェア実装 → light へ
    bg.setWeather(0);
    bg.onFallback = (why) => { bg.stop(); /* ここでも light へ差し替える */ };
  };
  document.head.appendChild(s);
</script>

bg-synapse.js と同じ表面(start / stop / setConfig / setWeather)を実装しているので、 bg-manager.js は2つを無改造で入れ替えられます。setTheme は背景の線には無い、こちら専用の口です。

ブラックホール — Black hole

サイト内に 2 つあります。アプリ一覧・このページをゆっくり横切る徘徊するブラックホール(/static/js/bh-wanderer.js + 起動係の /static/js/bh-ambient.js)と、1 画面で完結するゲーム Singularity(/static/blackhole.html)。描画の原典はどちらもアプリ側の synapse-link です。

徘徊するブラックホール

Flutter 版 BlackHoleRenderer.draw() をそのまま Canvas 2D に置き換えたもの
size stage

6 つの層

  1. 1
    Distortion Rings — 重力圏の同心円

    4 本の輪(pullDistance の 40%–100%)と、中心から外へ抜ける放射グラデーション。いちばん濃いところで rgba(139, 92, 246, 0.06) しかない。ここは「見える」より「気配がある」を狙う層。

  2. 2
    Ambient Particles — 吸い込まれていく粒子

    12 + radius × 0.5 個。半径の 1.5–4.0 倍の距離に置き、少しずつ内側へ寄せる。事象の地平面(0.5 倍)に届いたら外周へ戻して alpha を引き直す。色は紫(270–300)かオレンジ(20–40)のどちらか。

  3. 3
    Accretion Disk — 降着円盤

    加算合成(globalCompositeOperation: 'lighter')。3 層 × 60 分割の円弧で、層ごとに半径・線幅・回転速度を変える。色相は t × 120 + 260 + layer × 30 なので、紫から回して外側に暖色が乗る。

  4. 4
    Event Horizon Glow — 事象の地平面のにじみ

    半径 r × 1.4 × pulse の放射グラデーション。紫 #a855f7 と暖色 #d97757 を 0.3 / 0.7 の位置に置いて、内側は紫、外周は赤みへ倒す。

  5. 5
    Core — 本体

    外側は blur(7px) 付きのグラデーションで縁をにじませ、内側 0.6r はほぼ真っ黒でベタ塗り。中心を完全な黒にすることで、周りの加算合成の明るさが際立つ。

  6. 6
    Tactical Info — VOID タグ

    VOID: xx.x% と照準のかぎ括弧。値は danger = radius / (画面幅 / 4)。危険度が上がるほど色が紫 → 赤 → 金へ動き、0.8 を超えると点滅する。書体は --nzw-font-game(Rajdhani)。

大きさ(原典の表そのまま)

段階半径重力圏速度自然発生
tiny10px60px36 px/秒する
small20px95px24 px/秒する
medium35px130px12 px/秒する
large55px190px6 px/秒しない(原典では合体のみ)
huge80px300px3 px/秒しない(原典では合体のみ)

大きいほど遅い。質量が大きいほど鈍いという原典のルールをそのまま持ってきています。 Web 側で湧くのは tiny / small / medium の 3 段階から等確率。

徘徊のさせ方

動きだけは原典に無い追加分です(ゲームでは重力計算で動く)。「直進して壁で跳ね返る」を避けるための作りになっています。

やること値ねらい
角速度をランダムウォークturnJitter 1.7 rad/s²
turnDamping 0.8 /s
maxTurnRate 0.9 rad/s
向きを直接ランダムにすると小刻みに震える。曲がり方(角速度)の側を揺らして減衰させると、しばらく同じ向きに曲がり続けて弧を描く
端では中央へ向き直るedgeMarginRatio 3.0
edgeTurnGain 4.0
跳ね返りではなく緩やかな旋回。ゲインが小さいと端を舐めるように滑って戻ってこない
上端に立入禁止topInset 64px固定ヘッダーの下に潜るとタップできなくなるため
歩調を揺らすpaceCycle 47s
pacePower 0.65
長周期のサインで速度を上下させ、たまに歩みが遅くなるようにする
稀に○を吐くspitEveryMs 10000〜24000
spitLifeMs 4200
spitMax 2
spitSizeRatio 0.22
spitDrift 110px
跡は残さない。10〜24 秒に 1 個だけ小さな○を吐き、本体から離れる向きへ流れながら 4.2 秒で消える。間隔は毎回引き直す(等間隔だと「一定周期で出る装置」に見える)。向きは全周から引く — 進行方向の後ろに寄せると尾に見えて、やめたはずの跡に戻るため。飛距離 110px は重力圏(tiny 60px / medium 130px)を抜けるための値で、これより短いと本体の暈に飲まれて出たことが分からない。○は 2 色重ね(淡い紫の輪=暗い地用/濃い黒の芯=明るい地用)。実測 3.4 個/分、画面に居る割合は 24%。要素モード(スマホ)では canvas ごと動くので出さない
タップ判定半径 × 1.7(最小 22px)canvas 全面を pointer-events: auto にするとページが操作不能になるので、本体に追従する丸い <button> を別に置いて毎フレーム transform で動かす

置き場所

もとはトップページに置いていましたが、やめました。ヒーローと製品カードの真上を 黒い球が横切るのは、読み手にとって「意図の読めない動くもの」でしかなく、落ち着いて見られません。 いまはアプリ一覧(/apps/)・このページに放しています。 開発ノート(/static/notes/*)にも放していましたが、徘徊は要らないと判断して 2026-09-19 に外しました。 残した 2 ページは腰を据えて読むページで、視線は本文の列に固定されるので、その外側をゆっくり通り過ぎるぶんには 邪魔になりません。アプリ一覧は地が製図台(黒地に方眼)なので、絵としてはここがいちばん馴染みます。

起動係は /static/js/bh-ambient.js。canvas も当たり判定も CSS もこのファイルが差し込むので、 ページ側は <script> を 2 本読むだけで済みます。 読み込みから 1.2–2.7 秒置いてから body に .bh-visible が付き、 opacity が 0 → 0.8 へ 1.5 秒かけて上がります(いきなり居ると 「ページの一部」に見えてしまうため)。本体をタップすると Singularity が別タブで開きます。

script の data-*既定役目
data-chance1出現確率。0.5 にすれば「たまに出会う」側へ振れる
data-href/static/blackhole.html本体をタップしたときの行き先
data-top-inset64上端の立入禁止(px)。スティッキーヘッダーの下に潜るとタップできないため
data-delay1200徘徊を始めるまでの最短待ち(ms)。これに 0–1500ms のばらつきが乗る
data-label(既定は出す)"false" で 6 層目(VOID タグ + 本体まわりのフレア)を消す。本文の上に文字が重なるのを避けたいページ用
ページfront / behind彩度VOID タグ
アプリ一覧0.5 / 0.32saturate(0.3)消す
デザインシステム0.8 / 0.65そのまま出す

濃さと彩度はページ側で決めます。アプリ一覧は開発ノートの Dot Matrix を黒地に反転したページで、 「色は情報として使わない」テーマなので、原典の配色のままだと紫がページで唯一の彩度になってしまいます。 /apps/apps.css でそこだけ声量を落としています。

奥行きの入れ替わり

手前(z-index: 30/コンテンツより上・ヘッダーより下)と奥(z-index: -1/カードの裏)を 行き来します。奥にいる間はカードやセクションの背景に隠れ、半透明のところだけ透けて見えます。 当たり判定も一緒に沈むので、カードの上からは押せず、隙間なら押せます。

.bh-visiblez 30 / opacity .8手前
.bh-sunkopacity .08 / 450msいちばん薄いところで層を入れ替える
.bh-behindz -1 / opacity .65奥。カードの裏に回る
値既定意味
cycle24s奥 → 手前 → 奥 で一周する秒数
frontRatio0.60そのうち手前にいる割合
SWAP_MS450ms薄くしてから層を入れ替えるまで。CSS の .bh-swap の transition-duration と揃える

待ち時間には毎回 0.6–1.4 倍のばらつきを掛けます。きっちり等間隔で入れ替わると仕掛けが読めてしまうため。 トップページに置いていた頃は天気ごとに 4 通りの周期を持たせていましたが、開発ノート側に天気クラスが無いので、 この 1 通りに畳みました。周期を天気に結びつける必然性も、もともと薄かったところです。

重くしないための決め

絵は落とさない。落とすのは描き直す回数だけ

本体は重力圏ぶんの正方形(medium で 300px 角)にしか描かれないのに、以前は毎フレーム画面全体を消して 塗り直していました。1440×900 / dpr 2 で 1 フレーム 42ms(23fps)。原因はほぼこれです。

いまは本体をオフスクリーンに 1 枚描いて転送するだけにし、消すのも前フレームで汚した矩形だけにしています。 分割数も層数もぼかしも原典のまま。絵を粗くして稼げるのは 1 秒あたり 45ms 程度で、 外周の暖色リングが消えて青く寄る見た目の損に見合いませんでした。

品質spriteMsminFrameMs選ばれる条件
high330下の条件に当たらないとき
low6632粗いポインタ(指)/短辺 700px 未満/論理コア 4 以下 のいずれか

spriteMs はオフスクリーンを描き直す間隔で、位置の更新とは別です。粗くしても移動はなめらかなまま (速くても 36px/秒なので、遅くなるのは内部の揺らぎだけ)。maxDpr: 2 で 3 倍端末でも 2 倍までしか描きません。

Singularity — 1 画面のゲーム

/static/blackhole.html。徘徊するほうと違い、こちらは全画面・黒地・30 秒で終わる作りです。トップページのトーンとは意図的に切り離しています。

NORMAL0 – 29sドラッグで自機を弾く。星に当たると強く上へ跳ね返る
SUCKING29 – 30sゴールがブラックホールへ吸い寄せられる
HOLDING3sコアが白へ反転して静止。何も起きない 3 秒
BURSTING1sプラズマ 500 粒+画面の揺れ 55px
DANCING5s150 粒がゆっくり漂う余韻。そして COLLAPSED
決め値ねらい
配色地 #000
重力圏 rgba(255,30,0,.2)
自機・ゴール枠 #00f2ff / #fff
色数を 3 つに絞る。危険は赤、操作できるものは水色、目的地は白
書体font-weight 100
letter-spacing 0.8em
極細+広い字間。「静寂の宇宙」というトーンをタイポだけで出す
ブラックホールの成長半径 15px → 45px(線形)
重力圏 80px → 約 157px
重力圏は progress / 3.5 で伸ばす。等速で 350px まで伸ばすと途中で詰みになるため、意図的に 1/3 以下へ落としている
星に当たったとき角度 = 入射 × 0.4 + 真上 × 0.6
速度 = max(現速度, 22) さらに vy -6.5
物理的に正しい反射より「上へ行ける」を優先した補正。正しく跳ねさせると重力に負けて誰もゴールできない
タイマー240 × 2px / #ff1100 + glow画面下端の細い線 1 本だけ。数字を出すと残り時間ばかり見てしまう
明滅させない回転する 12 枚の放射グラデ点滅で危険を伝えるのをやめ、回転と赤の濃さ(0.1 + progress × 0.4)だけで進行を示す

コンポーネント — Components

トークンのみで構築した任意採用のベースクラス。新規ページはこれらを使うと統一が取れます。

Button .nzw-btn

Card .nzw-card

Featured

カードタイトル

hover でアクセントのグロー影が付きます。.nzw-card--hover

標準カード

罫線+ --nzw-shadow-sm + --nzw-radius-xl。

Badge .nzw-badge

Accent Success Warning Danger

プロジェクトカード — Labs & Prototypes

トップページの #projects-grid に並ぶカード。1 枚に「何か」「いつ」「どこで動くか」「どうやって手に入れるか」を全部載せる、という前提で組み立てています。

骨格は 1 種類、色だけプロダクトごとに変える

カードは 30 枚以上あり、それぞれ別のプロダクトです。並びも構造もバラバラにすると一覧として読めなくなるので、 寸法・角丸・影・行の並びは全枚数で共通にし、変えていいのは 「サムネの中身」と「面のごく淡い色みと影の色」(premium-bg-*)だけにしています。

面の色は rgba(255,255,255,0.8) から始まるグラデーションで、ブランド色が乗るのは終端の 50% 透過側だけです。強いブランド色をカード全体に敷くと、隣り合ったときに一覧がタイル状の色見本になってしまいます。

1 枚の解剖

1
GAME 2
SYNAPSE LINK
PHYSICS PUZZLE
3

SYNAPSE LINK

脳のシナプスをリンクさせる。直感的な操作と物理演算が融合した新感覚パズルゲーム。Web 版と iOS 版でリリース。

4
2026.02.18
5 Web iOS Android
  1. 1
    外枠

    rounded-2xl(--nzw-radius-xl / 16px)+ overflow-hidden + backdrop-blur-xl。枠線はプロダクト色の 100 番台を 80% 透過で(border-sky-100/80 など)。flex flex-col なので、説明文が短くてもフッターは必ず底に付く。

  2. 2
    サムネ 16:9

    aspect-video 固定。写真は object-cover で枠いっぱいに敷き、上からガラスのオーバーレイ(bg-white/20 + 下から上へのグラデーション + backdrop-blur-[1px])を重ねて、その上に大きなロゴタイトルを置く。左上にカテゴリのバッジ。ここだけがプロダクトごとに自由。

  3. 3
    本文

    px-5 pt-5。タイトルは text-xl font-bold、group-hover でアクセント色に変わる。説明は text-sm + line-clamp-3 で 3 行に固定。行数を固定しないと、カードの高さが揃わずグリッドがガタつく。

  4. 4
    メタ行

    mt-auto で底に寄せ、border-t で本文と切る。左が日付(YYYY.MM.DD)、右が 32px の円形アクション。ラベルを書かずアイコンだけにして、枚数が増えても行が伸びないようにしている。

  5. 5
    プラットフォームのバッジ帯

    手書きせず、カード内のリンクを読んで JS が後から差し込む。App Store リンクがあれば iOS、openApkSheet( か Play ストアがあれば Android、といった判定。ネイティブアプリのカードは「詳細ページへのリンク」を Web と誤認しないよう、明示的な Web ボタンか st/games/ の実行リンクがあるときだけ Web を付ける。

組み立て

<div class="project-card group backdrop-blur-xl border border-sky-100/80 rounded-2xl
            overflow-hidden card-hover flex flex-col relative
            premium-bg-synapse premium-card-content"
     data-category="game">
  <a href="…" class="flex flex-col flex-grow">
    <div class="aspect-video relative premium-image-synapse">…</div>
    <div class="px-5 pt-5 flex-grow flex flex-col">…</div>
  </a>
  <div class="px-5 pb-5 mt-auto">…</div>
  <!-- .plat-badge-strip は JS が追加する -->
</div>
クラス役目
.project-card絞り込みの対象。transition: all .4s var(--nzw-ease)
.card-hover浮き上がり。translateY(-6px) + 0 14px 26px -14px rgba(15,23,42,.16)。動かすのは transform と box-shadow だけ(レイアウトを触らない)
.premium-bg-*面のグラデーションと影の色。下のギャラリー参照
.premium-card-content夜モードで中の文字色・ボタン色をまとめて差し替えるための目印
groupカード hover でタイトル色を変えるための Tailwind グループ
data-categorygame / tool / learning / book / other
data-platform手書きしない。JS がリンクから判定して入れる

面の色 — premium-bg-*

共通の型は linear-gradient(145deg, rgba(255,255,255,.8) 0%, <プロダクト色 50% 透過> 100%) + box-shadow: 0 10px 30px -5px rgba(<プロダクト色>, .15), inset 0 1px 0 rgba(255,255,255,.8)。上辺の inset 1px がガラスの縁になります。トークンでは --nzw-shadow-accent が同じ形です。

synapse / workspaceSky · rgba(14,165,233,.15)
englyEmerald · rgba(16,185,129,.15)
mermaidIndigo · rgba(79,70,229,.15)
daygridBlue · rgba(37,99,235,.15)
mydictly紙色 · rgba(74,101,116,.15)
bom唯一の暗色。hover で枠が明滅する

premium-bg-bom だけ昼でも暗色で、hover 中は bom-danger-flash(1 秒周期で枠線の緑が明滅)を回します。 例外は 1 枚だけと決めています。2 枚目を作った時点で「淡い面で揃える」という取り決めが取り決めでなくなるため。

プラットフォームのバッジ

iOS Android Web その他
バッジ背景 / 文字判定
.plat-badge.ios#eef2f7 / #0f172aapps.apple.com へのリンクがある
.plat-badge.android#e7f7ed / #1f8a4copenApkSheet( か play.google.com がある
.plat-badge.web#e0f2fe / #0369a1その場で動くリンクがある(changelog やストアは除外)
.plat-badge.other#f3efe7 / #6b5e4aどれにも当たらなかったとき

帯は margin-top: auto でカードの底に貼り付き、rgba(255,255,255,.55) + backdrop-filter: blur(4px) でサムネや面の色から一段浮かせています。

絞り込み

UI中身
カテゴリアイコン+小文字ラベルのタブ(下線で選択を示す)all / book / apps / game / other。apps は learning と tool の 2 カテゴリをまとめて拾う
プラットフォーム丸いピル型のセグメント(狭い画面ではアイコンのみ)all / ios / android / web / other。カードの data-platform と突き合わせる
件数「もっと見る」初期 9 件、押すたびに +9。グリッドは 1 / md:2 / lg:3 列なので、9 は 3 行ちょうど
出し入れ—対象外は .hidden-card(display: none)。出すときは opacity 0 → 1、カード自体は fadeInCard(0.5s、下から 10px)で入る