diff --git a/CLAUDE.md b/CLAUDE.md index 4a957b95afa..0b79e8b6a56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,7 @@ Hexo 7.3 製の静的サイトで、GitHub Pages にホスティングされて | `themes/future/` | 自作テーマ。`layout/*.ejs`(EJS)と `source/css/`(Stylus) | | `scripts/` | Hexo の generator / helper 拡張(著者ページ、タグ、SNSカウント、OGPプレビューなど) | | `scaffolds/` | `hexo new` のテンプレート | +| `workers/likes/` | いいね機能の Cloudflare Worker(#1949)。Hexo のビルドには関わらない | | `_config.yml` | Hexo 本体設定(permalink、alias によるURLリダイレクト等) | | `themes/future/_config.yml` | テーマ設定(メニュー、GA4 プロパティ、SNSリンク) | | `_profile.yml` | 著者プロフィール(about / twitter_id / github_id) | @@ -609,6 +610,163 @@ make mermaid # mermaid 図のSVGキャッシュ更新(Docker必須、記事の - `.markdownlint-cli2.jsonc`: 行長・生URL・インラインHTMLなどは無効化済み - PR には reviewdog が textlint を回し、変更行にレビューコメントを付ける(`.github/workflows/reviewdog.yml`) +## いいね(Cloudflare Worker) + +記事の「役に立った」を受け取って数を出す(#1949)。**記事ページで JS を使う2つ目の機能** +(1つ目はリンクのコピー #2565)。保存先は Cloudflare Worker + KV で、 +コードは `workers/likes/` が持つ。 + +- **数の表示はビルド時に焼き込む。読者のブラウザからは読まない。** + 記事ページの表示ごとに問い合わせると、全記事ページに外部への通信が1本増える。 + はてブ数も PV も `update-cache.yml` が毎日取って JSON にコミットする形なので、 + いいねもそこへ乗せる。**JS が走るのは押したときだけ** + - 代償は「他人のいいねは翌日反映」。押した本人には POST のレスポンスの最新値を出す。 + 隣に並ぶはてブ数が同じ遅延なので、数の新しさが揃う + - Worker が持つのは `POST /like/<記事ID>` とバッチ用の `GET /dump` の2本だけで、 + 読者向けの `GET /count` は持たない +- **KV のキーは URL ではなく記事ID**(`like:20260804a`)。`_config.yml` の `alias` で + URL を付け替えても数が残る +- **数は値と `metadata` の両方に持つ。** ダンプは `list()` が返す metadata から読むので、 + 記事数ぶんの `get` を撃たずに済む(Workers の無料枠は1リクエストあたり50サブリクエスト + までで、1,500件を `get` で引くと超える)。実測で1,212件が `list()` 2ページ・18KB・32ms +- **Worker は記事の一覧を知らないので、記事IDは形式(`^20\d{6}[a-z]?$`)だけを見る。** + 存在しない記事IDでも書けてしまうが、ダンプを取り込む側が実在する記事だけを拾うので + 表示には出ない。記事IDの一覧を Worker に焼くと、記事を書くたびに再デプロイが要る +- **二重投稿は完全には防げない。** 認証を持たない以上、別ブラウザ・シークレット + ウィンドウからは押せる。技術ブログのいいねとしては許容する + - 端末側は `localStorage`、サーバ側は **IP と記事IDのハッシュ**を KV に TTL 24時間で置く。 + **生の IP は保存しない**(塩を足して SHA-256。記事ごとに別のハッシュになるので、 + 鍵が漏れても「どの端末がどの記事を押したか」の突き合わせにしか使えない) + - **連打と記事IDの総当たりは Rate Limiting binding が止める**(IPあたり60秒で20回)。 + KV を触らないので**書き込み枠を消費しない**。KV の無料枠は書き込みが1日1,000で、 + いいね1回につき2回(カウンタと重複記録)書くため、実質1日500いいねが上限になる + - **`Origin` が一致しないリクエストは受けない。** ブラウザ以外からの POST を + 完全には防げないが、素の叩き方は止まる +- **カウンタの `get` → `put` は競合しうる**(KV は結果整合)。同時に押されると取りこぼすが、 + いいね数は1件の差が読者の判断を変える数字ではないので Durable Objects は使わない + +**ボタンは共有ボタンと同じ行の左端に置く**(`.like-item`)。外へ出す導線(シェア)ではなく +「ここに残す」操作なので、丸の列との間を一段広く空ける(`ul` の gap 12px + `margin-right` 12px)。 +**器・ボタン・数字は共有ボタンと同じ部品を使う**——`li` は `.share-item`、ボタンは `.share-btn`、 +数字は `.share-count`。**CSS の差は幅と押した後の色だけ**で、地も高さも角丸もアイコンの +大きさも hover の反転もダークでの置き換えも、共有側の1つのルールから来る。 + +- **呼び名は「いいね」にそろえる。** issue の題は「役に立った」だが、ツールチップも + 読み上げ名も「いいね」にする +- **ラベルは持たない。マークだけ。** 役の違いは**幅**が示す——共有の4つが 28px の丸、 + いいねは **56px の錠剤**(ちょうど2倍)。**押す前の名前は `title`(OS のツールチップ)と + `sr-only`** が持つ。指したときに出すだけなので専用の吹き出しは要らない + - **地も枠も、押す前は共有ボタンと同じ。** 形(錠剤か丸か)と記号で既に分かれているので、 + 押す前に色の手は持たない。地を一段落とす案・ネイビーの枠にする案・ネイビーや + クリムゾンで塗りつぶす案も並べて比べたが、いずれも記事末で強く出すぎる +- **マークは拍手**(`clap` / `clap-filled`)。サムズアップは **Facebook の「いいね」と + 同じ記号で、すぐ隣に Facebook の共有ボタンが並ぶ**ので採らない。ハートは一覧と + 関連記事の ♡(総シェア数)と記号が重なる。拍手はどちらとも当たらない + - **Tabler に拍手が無い。** 線画は Phosphor Icons(MIT)、**塗りは Font Awesome Free + (アイコンは CC BY 4.0)**から借りる。**Phosphor の `fill` は後ろの手しか塗らず**、 + 線画とほとんど同じ絵になるため使えない + - **辞書の値は `{ viewBox, fillRule, paths }` のオブジェクトも取れる**(Tabler の + 24px 線画に合わない絵のため)。**`fill-rule` は既定の `evenodd` を `nonzero` に + 上書きする**——どちらもその前提で描かれていて、evenodd だと抜きが反転する + - Phosphor の線は Tabler の 2px ストロークより細いので、絵だけ 14px → 16px に上げる +- **1人が入れられるのは3つまで**(Medium の拍手と同じ形)。上限は Worker が持ち + (`MAX_PER_READER`)、クライアントは見た目を先に返すだけ + - **叩くたびに跳ねて祝いが出る。上限に届いた後も叩ける**——増えるのは見た目だけ + - **札(`+3`)と浮かぶ風船は別のもの。** + - **札は1つだけ、決まった場所に出す**(`.like-plus`)。出るのは3つ目に届いたとき + だけで、上限の後も同じ位置に出続ける。**散らばって重なると、そのぶん送られたように + 読めてしまう**——数字は動かさない。差分(`+1`)にしないのも同じ理由で、 + 動かない数の上で毎回 `+1` が出ると嘘になる + - **形はクリムゾンの塗りのチップに白い文字**(5.35)。素の文字だと風船と同じ色の + 点が並んで見分けが付かない + - **風船より前に出す**(`z-index`)。札が隠れると何個入ったかが読めなくなる + - **浮いた位置を基準に置く。** 引っ込むときは元いた場所でフェードするだけで + 下へは降ろさない。上がるのは出てくるときだけなので、`transition` ではなく + 入りのアニメーションで持つ + - **出ている間にもう一度叩かれたら、出し直さずに揺らす**(`is-shaking`)。 + 数は変わらないので、受け取ったことだけを返す + - **振れ幅は `%` ではなく px で書く。** 横位置は `translate(-50%)` で中央に + 寄せているので、そこを `-52%` にしても幅34pxのチップでは **0.7px** しか + 動かず見えない。いまは横 ±1px・縦 ±1.5px・回転 2度・拡大 1.03。 + **0.7px では見えず、±3px・9度・1.14 では強すぎた**ので、 + 見える下限に寄せている + - **浮かぶ風船は叩くたびに1つずつ別の要素を飛ばす**(`.like-float`)。使い回すと + 前のが浮いている間に次を出したときに前のが消える。左右のずれ(`--dx`)・ + 大きさ(`--size`)・出だしの遅れ(`--delay`)を順に変えて、同じ軌跡が重ならない + ようにする。**数は段が上がるほど増える**(1つ目は1個、3つ目は3個) + - **札を避けて左右へ抜ける。** まっすぐ上げると札の真下で渋滞して、 + 前の風船が消えたように見える。`--dx` は 0 の近くを持たない + - **待ちは1つ目から毎回引き直す。** 礼はこの待ちが引き金なので、3つ入れなくても出る + - **1つ目・2つ目・3つ目で祝いを一段ずつ強くする。** 強さは `li` の `is-level-N` が持ち、 + **CSS 変数**(`--pop-scale` / `--burst-alpha` / `--burst-bloom` / `--burst-spread`)で + 跳ねと光と点にまとめて効かせる。段を足すのは1箇所で済む + - **連打はまとめて1回送る**(叩き終わってから 800ms)。1つずつ送ると KV の書き込みを + 3倍消費する(無料枠は1日1,000)。読者には叩いた瞬間に数が動くので待ち時間は見えない +- **取り消しは長押し**(1000ms)。1つでも入れていれば効く。`POST /unlike/<記事ID>` が + その読者のぶんだけ引いて記録も消す + - **押し始めて 250ms で「長押しでキャンセルします」を出し、そこからもう一拍おいて + 実行する。** 取り消しは戻せないので、何が起きるかを先に見せる。 + **礼と同じ場所に出るので重ねない**——案内を出すときに礼を引っ込め、 + 案内が出ている間は礼を出さない。 + 案内の地は**グレー**(`ink-strong` を面として使う。白い文字で 10.05)—— + 礼のクリムゾンとも、ブランドのネイビーとも役が違う、ただの機械の案内なので + 色を持たせない。`ink-strong` はテーマで明暗が入れ替わるので、暗い側は文字の色だけ戻す + - **キーボードは長押しできないので、上限の後にもう一度押すと取り消しにする。** + 見分けは `click` の `detail`(キーボードで起こしたクリックは 0) + - **長押しの後の `click` は捨てる。** 押し下げと離しの両方が起きるので、 + そのままだと取り消した直後にもう1つ入る + - 指で押し続けたときに字を選んだり拡大したりさせない(`user-select: none` / + `touch-action: manipulation`) +- **押した後は絵・数字・枠をクリムゾンにそろえる。** 絵と数字だけが赤いと丸い枠が + 取り残される。差し色の「画面内で同時に1箇所」は守ったうえで、出るのは読者が + 入れた後だけなので、その1箇所が読者自身の行為を指す + - **暗い側は絵と枠を `crimson-on-dark` に替える**(`brand-crimson` は暗い地で 2.92。 + 一段明るい方は 3.46 で非テキストの 3:1 を満たす)。**数字は文字なので 4.5 が要り、 + `crimson-on-dark` でも 3.81 で届かない**ので赤くしない + - **押した後も反応を返すが、ネイビー反転は打ち消してクリムゾンで塗る。** + 赤い絵がネイビーの上に乗ると読めない +- **状態は3つ。押した後と hover を同じ見た目にしない** + - 押す前: 枠 `rule-base`、絵は線画 + - hover / focus: ネイビー反転(共有ボタン・タグチップと同じ型 #2686)。 + **`:focus` ではなく `:focus-visible`**——`.share-btn` が `:focus` で書かれていて、 + マウスで押した後もフォーカスが残る間ずっと反転したままになり、他を押すまで + 戻らなかった(#1949 で気づいて `.share-btn` ごと直した) + - 入れた後: **絵が塗りに変わる**(#2755 の「塗り=自分が押した状態」) +- **数字はボタンの下**(`.share-item` の縦積みそのまま)。押せる範囲と数字が別で + あることを位置で示す規則も、共有ボタンと同じ形で満たされる。0 のときは出さない。 + **件数は読み上げ名に畳む**(#3066 の共有ボタンと同じ) +- **叩いた瞬間に動くのは2つ** + - **絵が跳ねる**(`like-pop`。1 → 1.45 → 0.92 → 1)。速さは `like-pop-speed`(0.3s) + - **風船が上へ抜ける**(`like-plus-rise`)。速さは跳ねの2倍で、**新しい段は置かず + `like-pop-speed * 2` から導く**——同じできごとの中の2つの動きで、片方だけ + 別に決める理由が無い。行に場所を取らせると叩くたびに並びが動くので絶対配置にし、 + 左右いっぱいに広げて中央ぞろえにすればボタンの幅をここに書き写さずに中心がそろう + - **やわらかい光と、外へ散る小さな点**(`like-bloom` / `like-sparks`)。 + **硬い輪は衝撃波に見えた**ので輪郭を持たせない。点は `::after` を1つ置いて + `box-shadow` で作るので DOM は増えない(光は `::before`) + - **下へ飛ぶ点は持たない**(上と横だけの5つ)。ボタンの下には数字があり、 + そこに点が重なると読みにくい。浮かぶ風船の出だしも下へは下げない +- **風船が消えた後に、一度だけ「ありがとうございました!」を出す**(note の「すき」と + 同じ形)。**固定の待ち時間ではなく、風船が引っ込むのを引き金にする**——同じ場所に + 出るので重ねられず、連打すると風船がいつ消えるかは叩き終わりで決まる。 + 出している長さはコピーと同じ2秒 + - **以後は指しても出さない。** 一度きりの合図で、ボタンの名前ではない + - **地はクリムゾン**(白い文字で 5.35)。赤くなった絵と同じできごととして読ませる + - **入れたら `title` を外す**(取り消したら戻す)。すでに入れたボタンに「いいね」と + 出ても意味が無く、礼の吹き出しと二重にも見える + - **形は `.share-copied`(コピーの「コピーしました」)と1つのルールを共有し、 + 地と位置だけを変える** + - **左端はボタンにそろえる**(`.share-copied` の中央ぞろえを打ち消す)。礼の文は + 12px でも 150px 近くあり、56px のボタンの中央に置くと本文列の左端からはみ出す。 + 左ぞろえなら伸びるのは右側=共有の丸の上で、そこは空いている +- **`localStorage` と JS が無いときはボタンごと出さない。** リンクのコピー(#2565)と同じく + `li` に `hidden` を付けておき、送り先が設定されているときだけ script が外す +- **`prefers-reduced-motion: reduce` では跳ねも祝いも風船の動きも止め、結果だけを出す** + (#2686 と同じ線引き)。入れたことは絵の塗りと色と数字が示す +- **幅375pxでも折り返さない。** 器の幅がボタンの 56px で決まるので、丸が5つ + (2025年7月より前の記事=Pocket あり)でも 56 + 24 + 188 = 268px で + 本文列の 351px に収まる + ## ブランチ / デプロイ - 作業は `feature` ブランチで行い、`main` へ PR を出してマージする @@ -816,13 +974,14 @@ bootstrap-subset → theme-styles.styl の順で `/css/site.css` に連結する **767px 以下のコードと note**(`margin-inline: calc(50% - 50vw)` で画面端まで。 `.article-entry` 直下のものだけ #2568)。記事末の帯(#2874)は全幅だが、 中の `.container` が他ページと同じ左右を持つ -- **時間の段は2つ+例外1つ**(#3222)。`_variables.styl` が持つ。**役で選ぶ** +- **時間の段は2つ+例外2つ**(#3222 / #1949)。`_variables.styl` が持つ。**役で選ぶ** | 段 | 値 | 使うところ | | --- | --- | --- | | `hover-speed` | 0.05s | 反応。色・面・下線が変わるところすべて | | `state-speed` | 0.18s | 状態の変化。タブの切り替え・ドロップダウンと引き出しの開閉・検索窓の伸縮・折りたたみの開閉 | | `footer-underline-speed` | 0.25s | 例外。フッターのリンクの下線(#2839) | + | `like-pop-speed` | 0.3s | 例外。いいねを押した瞬間の跳ね(#1949) | - **状態の変化の側も1つだけ。** 以前は 0.12 / 0.15 / 0.18 / 0.2s が部品ごとに散っていて、 役が4つあるのではなく別々に決めた値が残っていただけだった。0.02〜0.05s の差は diff --git a/scripts/likes.js b/scripts/likes.js new file mode 100644 index 00000000000..1a3f10709b4 --- /dev/null +++ b/scripts/likes.js @@ -0,0 +1,40 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +/** + * いいねの数(#1949)。 + * + * 数は Cloudflare Worker + KV が持ち、update-cache.yml が毎日ダンプして + * like_count_cache.json にコミットする。表示はここで焼き込むので、 + * 読者のブラウザから数を読みに行くリクエストは発生しない。 + */ + +// permalink の :year:month:day:postid と同じ形。KV のキーもこの ID で、 +// _config.yml の alias で URL を付け替えても数が残る +const POSTID = /^20\d{6}[a-z]?$/; + +// scripts/ はホットリロードされないので、プロセスの生存中は読み直さない +let counts = null; + +function load() { + if (counts) return counts; + try { + const file = path.join(hexo.base_dir, 'like_count_cache.json'); + counts = JSON.parse(fs.readFileSync(file, 'utf8')).likes || {}; + } catch (e) { + // バッチが回るまでは存在しない。0本として描く + counts = {}; + } + return counts; +} + +hexo.extend.helper.register('like_post_id', function (post) { + const matched = /articles\/([^/]+)\//.exec((post && post.path) || ''); + return matched && POSTID.test(matched[1]) ? matched[1] : ''; +}); + +hexo.extend.helper.register('like_count', function (postid) { + return load()[postid] || 0; +}); diff --git a/themes/future/_config.yml b/themes/future/_config.yml index db76321a34b..5c82b173ce1 100644 --- a/themes/future/_config.yml +++ b/themes/future/_config.yml @@ -14,6 +14,10 @@ featured_count: 10 # min_count は旧実装(全期間シェア数順)の閾値で、置き換えに伴い廃止した tag_display_max_count: 10 +# いいねの送り先 (#1949)。Cloudflare Worker の URL。 +# 空なら押しても何も起きないのでボタンごと出さない +likes_endpoint: + # Integrations(GA4 プロパティ) google_analytics: G-X1C28R8H0M diff --git a/themes/future/css-src/_variables.styl b/themes/future/css-src/_variables.styl index 3b2dc979688..a274a90034a 100644 --- a/themes/future/css-src/_variables.styl +++ b/themes/future/css-src/_variables.styl @@ -460,6 +460,14 @@ footer-underline-speed = 0.25s // 間隔の 4px 刻みとは別の系統なので、space-step からは導かない link-underline-offset = 2px +// いいねを押した瞬間の跳ね(#1949)。時間の段の2つ目の例外で、 +// いいねボタンでしか使わない。状態の変化(state-speed)と役が違う—— +// あちらは「切り替わったことが分かる程度で待たされない長さ」だが、 +// こちらは一度きりの合図なので、跳ねて戻るところまで見えないと意味が無い。 +// 0.18s では往復が潰れる。押せるのは1記事につき1回で、 +// 読んでいる途中に何度も出るものではない +like-pop-speed = 0.3s + // 状態が変わったことを見せる速さ。反応(hover-speed)とは役が違うので別に持つが、 // **状態の変化の側も1つだけ**。タブの切り替え・ドロップダウンと引き出しの開閉・ // 検索窓の伸縮・折りたたみの開閉が対象で、中身が入れ替わったことが分かる程度で diff --git a/themes/future/css-src/theme-styles.styl b/themes/future/css-src/theme-styles.styl index 13e2cccf86e..b98d12c142a 100644 --- a/themes/future/css-src/theme-styles.styl +++ b/themes/future/css-src/theme-styles.styl @@ -225,8 +225,29 @@ dark-parts() .share-btn { color: var(--ink-strong); } + /* 押した後の絵と枠 (#1949)。brand-crimson は暗い地で 2.92 しか出ないので、 + 一段明るい crimson-on-dark に替える(3.46。非テキストの 3:1 を満たす) */ + .like-button[aria-pressed="true"] { + color: var(--crimson-on-dark); + border-color: var(--crimson-on-dark); + } + /* 長押しの案内の地は ink-strong で、暗い側では明るいグレーになる */ + .like-hold { + color: var(--surface-base); + } + /* 指しているときはクリムゾンで塗る(明るい側と同じ)ので、絵は白に戻す。 + 暗い側の静止の指定の方が詳細度が高いため、ここで上書きが要る */ + .like-button[aria-pressed="true"]:hover, + .like-button[aria-pressed="true"]:focus-visible { + color: var(--on-dark-strong); + } + /* 数字は文字なので 4.5 が要る。crimson-on-dark でも 3.81 で届かないため、 + 暗い側だけ赤くしない。押したことはハートの塗りと色が示す */ + .like-button[aria-pressed="true"] ~ .like-count { + color: var(--ink-strong); + } .share-btn:hover, - .share-btn:focus { + .share-btn:focus-visible { background: var(--ink-strong); border-color: var(--ink-strong); color: var(--surface-base); @@ -2703,9 +2724,13 @@ ul.social-button li { } /* タグチップ(.tag-list-link)と同じネイビー反転。小さな操作部品の反応を 1種類にそろえる (#2686)。枠も同色にしないと縁だけ残って見える。 - アイコンは fill: currentColor なので color の白がそのまま効く */ + アイコンは fill: currentColor なので color の白がそのまま効く。 + + **:focus ではなく :focus-visible**(#2686 の規則どおり)。:focus だと + マウスで押した後もフォーカスが残る間ずっと反転したままになり、 + 他を押すまで戻らない(#1949 で気づいた) */ .share-btn:hover, -.share-btn:focus { +.share-btn:focus-visible { background: var(--brand-navy); border-color: var(--brand-navy); color: var(--on-dark-strong); @@ -2732,8 +2757,11 @@ button.share-btn { margin-right: 0; vertical-align: 0; } -/* コピーの合図は2つ。円の中のアイコンをチェックに差し替え、円の上に - 「コピーしました」の吹き出しを出す。アイコンだけだと何が起きたか読み取れない。 +/* ボタンの上に出る吹き出し。コピーの「コピーしました」と、いいねの名前 + (#1949。指している間だけ出す)が共有する。 + + コピーの合図は2つで、円の中のアイコンをチェックに差し替え、円の上に + 吹き出しを出す。アイコンだけだと何が起きたか読み取れない。 吹き出しは absolute で浮かせる。行に場所を取らせると、押した瞬間に ボタンの並びが動く。押せる部品ではないので pointer-events も切る */ @@ -2781,6 +2809,286 @@ button.share-btn { font-variant-numeric: tabular-nums; } +/* いいね (#1949)。器・ボタン・数字は共有ボタンと同じ部品を使い、違うのは + 横幅と押した後の状態だけ。 + + 外へ出す導線(シェア)ではなく「ここに残す」操作なので、丸の列との間を + 一段広く空け(gap 12px + 12px)、**幅**で分ける——丸28pxに対して + 56px(ちょうど2倍)の錠剤。 + + 地も枠も共有ボタンと同じ。マークがハートなので、形と記号で既に分かれている */ +.like-item { + margin-right: 12px; + /* 祝いの強さ。1つ目・2つ目・3つ目で一段ずつ上げる。跳ね・光・点が + 同じ変数を見るので、段を足すのはここ1箇所で済む */ + --pop-scale: 1.3; + --burst-alpha: 0.22; + --burst-bloom: 1.05; + --burst-spread: 0.85; +} +.like-item.is-level-2 { + --pop-scale: 1.42; + --burst-alpha: 0.3; + --burst-bloom: 1.2; + --burst-spread: 1.05; +} +.like-item.is-level-3 { + --pop-scale: 1.55; + --burst-alpha: 0.4; + --burst-bloom: 1.4; + --burst-spread: 1.3; +} +.like-button { + width: 56px; + /* 上限まで入れた後は長押しで取り消す。指で押し続けたときに字を選んだり + 拡大したりさせない */ + user-select: none; + -webkit-touch-callout: none; + touch-action: manipulation; +} +/* Phosphor の線画は塗りで描かれていて Tabler の 2px ストロークより細い。 + 共有の丸のロゴ(14px)と同じ大きさだと弱く見えるので一段上げる */ +.like-button .svg-icon { + width: 16px; + height: 16px; +} +/* 押した後はマークの塗りで示す (#2755)。地も枠も動かさない——面は hover が + 使っており、枠は共有ボタンと共通のままにしてある。 + + 色はクリムゾン。差し色の「画面内で同時に1箇所」を守ったうえで、 + 出るのは読者が押した後だけなので、その1箇所が読者自身の行為を指す。 + **枠も同じ色にする**——絵と数字だけが赤いと、丸い枠だけが取り残される */ +.like-button[aria-pressed="true"] { + color: var(--brand-crimson); + border-color: var(--brand-crimson); +} +/* 押した後は下の数字もそろえる。ハートだけ赤くて数字がネイビーだと、 + 1つの部品の中で色が2つになる */ +.like-button[aria-pressed="true"] ~ .like-count { + color: var(--brand-crimson); +} +/* 押した後も叩ける(Medium の拍手と同じ)ので反応は返すが、ネイビー反転は + 打ち消す。赤いマークがネイビーの上に乗ると読めない。地の方を赤で塗る */ +.like-button[aria-pressed="true"]:hover, +.like-button[aria-pressed="true"]:focus-visible { + background: var(--brand-crimson); + border-color: var(--brand-crimson); + color: var(--on-dark-strong); +} +/* 押した瞬間の合図。動くのはマークだけ。広がって消える輪も試したが、 + 枠と同じ形の線が外へ出ていくので衝撃波に見えた。 + + 引き金は aria-pressed ではなく script が足す is-liking。属性で回すと、 + 前に押した記事を開き直すたび(localStorage から復元するたび)に跳ねる */ +@keyframes like-pop { + 0% { transform: scale(1); } + 35% { transform: scale(var(--pop-scale)); } + 65% { transform: scale(0.92); } + 100% { transform: scale(1); } +} +.like-button.is-liking .like-icon-filled { + animation: like-pop like-pop-speed ease-out; +} +/* 叩いたときの祝い。**硬い輪は衝撃波に見えた**ので、輪郭を持たない光(::before)と、 + 外へ散る小さな点(::after を1つ置いて box-shadow で6つ)にする。DOM は1つで済む。 + + 速さは跳ねの2倍。**新しい段は置かず like-pop-speed から導く**——同じできごとの + 中の動きで、片方だけ別に決める理由が無い */ +.like-burst { + position: absolute; + left: 0; + right: 0; + /* ボタン(高さ 28px)の中心 */ + top: 14px; + height: 0; + pointer-events: none; +} +.like-burst::before { + content: ''; + position: absolute; + left: 50%; + top: 0; + width: 84px; + height: 84px; + margin: -42px 0 0 -42px; + border-radius: 50%; + background: radial-gradient(circle, var(--brand-crimson) 0%, transparent 68%); + opacity: 0; +} +.like-burst::after { + content: ''; + position: absolute; + left: 50%; + top: 0; + width: 5px; + height: 5px; + margin: -2.5px 0 0 -2.5px; + border-radius: 50%; + /* **下へ飛ぶ点は持たない。** ボタンの下には数字があり、そこに点が重なると + 読みにくい。上と横だけの5つにする */ + box-shadow: 0 -28px 0 var(--brand-crimson), 20px -20px 0 var(--brand-crimson), + -20px -20px 0 var(--brand-crimson), 28px -3px 0 var(--brand-crimson), + -28px -3px 0 var(--brand-crimson); + opacity: 0; + transform: scale(0.25); +} +@keyframes like-bloom { + from { opacity: var(--burst-alpha); transform: scale(0.5); } + to { opacity: 0; transform: scale(var(--burst-bloom)); } +} +@keyframes like-sparks { + 0% { opacity: 1; transform: scale(0.25); } + 60% { opacity: 1; } + 100% { opacity: 0; transform: scale(var(--burst-spread)); } +} +.like-burst.is-bursting::before { + animation: like-bloom (like-pop-speed * 2) ease-out; +} +.like-burst.is-bursting::after { + animation: like-sparks (like-pop-speed * 2) ease-out; +} +/* 光と点は絵の後ろ。ボタンを持ち上げて重なりを決める */ +.like-button { + position: relative; + z-index: 1; +} + +/* 叩いた数の風船。行に場所を取らせると叩くたびに並びが動くので絶対配置にする。 + 左右いっぱいに広げて中央ぞろえにすれば、ボタンの幅をここに書き写さずに + 中心がそろう。 + + **札は1つだけ、決まった場所に出す。** 散らばって重なると、そのぶん送られたように + 読めてしまう。浮かんで散るのは装飾の風船(.like-float)の役 */ +.like-plus { + position: absolute; + left: 50%; + bottom: 100%; + margin-bottom: 4px; + padding: 3px 8px; + border-radius: 999px; + background: var(--brand-crimson); + color: var(--on-dark-strong); + font-size: text-meta; + line-height: 1; + font-weight: bold; + white-space: nowrap; + opacity: 0; + pointer-events: none; + /* 風船より前。札が隠れると何個入ったかが読めなくなる */ + z-index: 3; + /* **浮いた位置を基準に置く。** 引っ込むときは元いた場所でフェードするだけで、 + 下へは降ろさない。上がるのは出てくるときだけなので、transition ではなく + 入りのアニメーションで持つ */ + transform: translate(-50%, -16px); + transition: opacity like-pop-speed ease-out; +} +.like-plus.is-shown { + opacity: 1; + animation: like-plus-in like-pop-speed ease-out; +} +@keyframes like-plus-in { + from { opacity: 0; transform: translate(-50%, 0); } + to { opacity: 1; transform: translate(-50%, -16px); } +} +/* 出ている間にもう一度叩かれたら揺らす。数は変わらないので、受け取ったことだけを返す。 + is-shown の入りより後ろに置いて上書きする。 + + **振れ幅は % ではなく px で書く。** 横位置は translate(-50%) で中央に寄せているので、 + そこを 52% にしても幅34pxのチップでは 0.7px しか動かず見えない。 + 跳ねと同じく拡大も混ぜて、小さな札でも動きが読めるようにする */ +@keyframes like-plus-shake { + 0%, 100% { transform: translate(-50%, -16px) rotate(0) scale(1); } + 30% { transform: translate(calc(-50% - 1px), -17.5px) rotate(-2deg) scale(1.03); } + 65% { transform: translate(calc(-50% + 1px), -15.5px) rotate(1.5deg) scale(1.015); } +} +.like-plus.is-shaking { + animation: like-plus-shake like-pop-speed ease-in-out; +} + +/* 浮かぶ風船。叩くたびに1つずつ別の要素を飛ばすので、前のが浮いていても消えない。 + 左右のずれ(--dx)・大きさ(--size)・出だしの遅れ(--delay)を順に変えて、 + 同じ軌跡が重ならないようにする。**数は段が上がるほど増やす**(呼ぶ側が決める)。 + 速さは跳ねの5倍——**新しい段は置かず like-pop-speed から導く** */ +.like-float { + position: absolute; + left: 50%; + bottom: 100%; + width: var(--size, 6px); + height: var(--size, 6px); + margin-left: calc(var(--size, 6px) / -2); + border-radius: 50%; + background: var(--brand-crimson); + opacity: 0; + pointer-events: none; + /* 札の後ろ、ボタンの前 */ + z-index: 2; +} +/* 札を避けて左右へ抜ける。まっすぐ上へ上げると札の真下で渋滞する */ +@keyframes like-float-rise { + /* 出だしも下へは下げない(同じ理由) */ + 0% { opacity: 0; transform: translate(0, 0) scale(0.4); } + 25% { opacity: 0.9; transform: translate(calc(var(--dx, 0) * 0.5), -10px) scale(1); } + 100% { opacity: 0; transform: translate(var(--dx, 0), -52px) scale(0.85); } +} +.like-float.is-rising { + animation: like-float-rise (like-pop-speed * 5) ease-out var(--delay, 0ms) both; +} +/* 長押しの案内。取り消しは戻せないので、何が起きるかを先に出す。 + 地はグレー(ink-strong を面として使う。白い文字で 10.05)——礼のクリムゾンとも、 + ブランドのネイビーとも役が違う、ただの機械の案内なので色を持たせない。 + **ink-strong はテーマで明暗が入れ替わる**ので、暗い側は文字の色だけ戻す */ +.like-hold { + left: 0; + transform: none; + background: var(--ink-strong); +} +.like-hold.is-holding { + opacity: 1; +} +/* 押した少し後に一度だけ出す礼。指しても出ない(押す前の名前は title が持つ)。 + 吹き出しの形は .share-copied が1箇所で持ち、ここは地と位置だけを変える。 + + **地はクリムゾン。** 押した瞬間に出るものなので、赤くなったハートと同じ + できごととして読ませる。白い文字で 5.35。差し色の「画面内で同時に1箇所」は + ハートと吹き出しが同じ合図なので割れない。 + + **左端をボタンにそろえる**(.share-copied の中央ぞろえを打ち消す)。 + 礼の文は 12px でも 150px 近くあり、72px のボタンの中央に置くと本文列の + 左端からはみ出す。左ぞろえなら伸びるのは右側=共有の丸の上で、そこは空いている */ +.like-thanks { + left: 0; + transform: none; + background: var(--brand-crimson); +} +.like-thanks.is-thanks { + opacity: 1; +} +/* 動きを止める設定では結果だけを出す。押したことはマークの塗りと色が示すので、 + 合図そのものは失われない (#2686 と同じ線引き) */ +@media (prefers-reduced-motion: reduce) { + .like-button.is-liking .like-icon-filled, + .like-burst.is-bursting::before, + .like-burst.is-bursting::after { + animation: none; + } + .like-plus { + transition: none; + } + .like-plus.is-shown, + .like-plus.is-shaking { + animation: none; + } + .like-float.is-rising { + animation: none; + } +} +.like-icon-filled, +.like-button[aria-pressed="true"] .like-icon-outline { + display: none; +} +.like-button[aria-pressed="true"] .like-icon-filled { + display: block; +} /* アイコンだけのリンク(GitHub / X / Youtube / RSS)。フッターと著者ページの プロフィール (#2774) で共有する。下線は文字のための印なので消し、反応は チップと同じ「地を一段」で示す。タップ領域は Lighthouse の diff --git a/themes/future/layout/_partial/social-button.ejs b/themes/future/layout/_partial/social-button.ejs index f14fbaddd86..a270ff7c39c 100644 --- a/themes/future/layout/_partial/social-button.ejs +++ b/themes/future/layout/_partial/social-button.ejs @@ -37,6 +37,44 @@ }; %>