0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIに書かせたHTML/CSSを168ファイル分測ってみた。font-size宣言の50%が14px未満で最小7pxだった

0
Last updated at Posted at 2026-09-04

個人開発のプロジェクトを8本、ほぼ全部のCSSをAIに書かせて作ってきました。

スマホで自分のサイトを開くたびに「なんか字が小さいな」と思っていたんですが、感想のままだと何も直せません。どのファイルの、どの宣言が、いくつ小さいのか分からないからです。

そこでソースを機械的に数えるだけの短いスクリプトを書いて、8プロジェクト全部にかけました。標準ライブラリだけの1ファイル、100行ちょっとです。

出た数字がこれでした。

実行結果

font-size の宣言 2,401件のうち 1,200件(50.0%)が14px未満。最小は 7px

自分で「小さい気がする」と思っていたのは正しかったんですが、半分だとは思っていませんでした

この記事では、そのスクリプトの全文と、測って分かったこと、そしてこの測り方で分からないことを書きます。数字は全部 2026-09-04 に手元で実行した実測値で、環境は Python 3.9.6 / macOS 26.5 です。


何を測ったのか

走査したもの

  • 個人開発の8リポジトリ
  • .html .css .scss など 168ファイル
  • node_modules .git dist build vendor は除外
  • 拾うのは font-size: ○px と、Tailwind の任意値 text-[○px] の両方

Tailwind を混ぜたのは、片方だけ数えると「Tailwind で書いた面が丸ごと見えなくなる」からです。実際、8本のうち1本は font-size: の直接指定が12宣言しかなく、それだけ見ると「優等生」に見えてしまいます。

数えた項目は4つだけ

項目 何を見るか なぜ見るか
font-size 14px 未満の件数と最小値 読めるかどうかの下限
ボタンの height / min-height 48px 未満の件数 押せるかどうかの下限
font-weight 800 / 900 の件数 サイズでなく太さで階層を作っているサイン
border-radius 値の種類数 「設計」か「その場の判断」かの指標

どれもソースを正規表現で拾うだけです。ブラウザは使いません。使わないことによる限界は後半に全部書きます。

出た結果

font-size分布

12〜13.9px の帯が 839件で最大でした。「本文にはギリギリ小さいが、補助テキストとしてなら通ってしまう」サイズです。ここに一番溜まるのは、たぶん一つ一つは間違っていないからです。

問題は、それが2,401件のうち半分を占めていることのほうでした。


プロジェクトごとに 0%〜69% に割れた

プロジェクト別

同じ人間が、同じAIコーディング環境で、数週間のうちに作ったプロジェクトなのに、14px未満の割合が 0% から 69% まで割れました

割れた理由は「その日にどう頼んだか」だった

心当たりを突き合わせると、差が出たのは技術ではなく指示の出し方でした。

  • 「情報量を詰めて」と頼んだ日 → 小さい文字が増える
  • 「見やすくして」と頼んだ日 → 大きくなる
  • 何も言わなかった日 → その時々でバラバラ

AIは指示された範囲では正しく書きます。ただ、基準を渡していない項目については、毎回ゼロから決め直します。人間のように「前回12pxにしたから今回も12px」とは考えてくれません。だから同じ人の同じプロジェクト群の中で、規律だけが揃わないという状態になります。

「あとで直す」が効かないサイズになっていた

1,200件を手で直すのは現実的ではありません。しかもこの1,200件は「全部14pxに置換すればいい」というものでもなくて、意図して小さくしている凡例やキャプションも混ざっています。

測る前は「気が向いたら直す」つもりだったのが、数を見た時点で「置換では直らない」に変わりました。 これがスクリプトを書いていちばん得したところです。


タップ標的は「小さい」より「指定が無い」が問題だった

タップ標的

button / .btn / input などのセレクタに付いた高さ指定は、168ファイル全体で114件しかありませんでした。そのうち48px未満が49件(43.0%)。

母数が少なすぎるのが本題

8プロジェクトぶんのボタンが114個しかないはずがありません。実際には、大半のボタンが高さを一切指定せず padding 任せになっていました。

つまりこの49件は氷山の一角で、padding: 6px 10px のような指定で実質40px以下になっているボタンは、このスクリプトでは1件も検出できていません。ソースを読むだけの検査の限界がいちばん露骨に出た項目でした。

48px という数字はどこから来ているのか

ここは推測で決めたくなかったので、一次資料を当たりました。

基準

出典 数値 位置づけ
WCAG 2.2 SC 2.5.8 Target Size (Minimum) 24 × 24 CSS px レベル AA(例外あり)
WCAG 2.2 SC 2.5.5 Target Size (Enhanced) 44 × 44 CSS px レベル AAA
Apple Human Interface Guidelines 44 × 44 pt Apple の指針
Material Design 3 48 × 48 dp Google の指針

48px は、この4つを同時に満たす最小値です。だから48pxをしきい値にしました。「なんとなく大きめ」ではなく、こう決めた根拠が言える値にしておくと、後からAIに指示を出すときにそのまま渡せます。


font-size の「14px」には規格の裏付けが無い

ここは記事の中でいちばん注意して書きます。間違って覚えていたのは自分でした。

WCAG に最小フォントサイズの達成基準は存在しない

WCAG 2.2 を通しで見ても、「本文は◯px以上」という達成基準はありません。近いのは SC 1.4.4 Resize Text ですが、これは200%まで拡大しても情報や機能が失われないことを求めるもので、初期表示のpx値を縛るものではありません。SC 1.4.12 Text Spacing も同じく、行間や字間を変えられることを求める基準です。

つまり 「14px未満はアクセシビリティ違反」と言うと嘘になります

数字が明示されているのは Lighthouse のほう

一方で Lighthouse の SEO 監査「Document uses legible font sizes」 には具体的な数字があります。

ページ内テキストの 40%以上が12px未満だと失格。60%以上を12px以上にすることが推奨。

自分のプロジェクトを同じ12pxで測り直すと、2,401宣言のうち 361件(15.0%)が12px未満でした。Lighthouse の基準では通ってしまいます。

だから14pxは「規格」ではなく「取り決め」

  • 12px … Lighthouse の失格ライン(宣言ベースでは15%)
  • 14px … 自分たちで決めた運用ライン(宣言ベースでは50%)

14pxに根拠が無いわけではなくて、12pxを下限にすると「ギリギリ合格」を量産するから、余裕を持たせて14pxにしたというだけです。この違いをはっきりさせておかないと、チームやAIに「WCAG違反だから直して」と誤った理由で指示を出してしまいます。

px を使うこと自体の是非は MDN の font-size値と単位の解説 が詳しいです。rem に寄せればユーザーのブラウザ設定に追随できますが、**今回の問題は単位ではなく「基準を持たずに毎回決めていること」**なので、まず数を数えました。


border-radius が 25種類あった

border-radius

これは font-size より分かりやすい症状でした。

2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20, 22, 24, 26, 70, 99, 130, 999, 9999

2pxと3px、10pxと11px、13pxと14pxの差を知覚できる人はいません。 これは意図ではなく、その都度その場で数字を決めた記録です。

種類数は「規律の代理指標」として使える

font-size の分布は「意図的に小さくした箇所」が混ざるので白黒がつけにくいのですが、border-radius の種類数は言い訳が効きません。4/8/12/16/24/9999 のような数個で足りるものが25種類あるなら、それは設計が無いということです。

同じ理屈で font-weight: 800/900 も数えました。831件ありました。font-weight を上げるのは見出しを作るのに手軽ですが、サイズのコントラストが足りないのを太さで補っている状態が多く、これも「その場で決めた」のサインとして数えています。


スクリプト全文

依存ゼロ・1ファイルです。コピペして自分のリポジトリに向けてください。

使い方

python3 css_smell.py ./src
python3 css_smell.py ./src --json    # CIに繋ぐとき

コード

#!/usr/bin/env python3
"""css_smell.py - HTML/CSS のソースを読んで「小さすぎる文字」「小さすぎるタップ標的」
「太さで階層を作っている箇所」を数える。依存ライブラリなし・標準ライブラリだけ。

  python3 css_smell.py <dir or file> [...]
  python3 css_smell.py ./src --json     # 機械可読で出す(CIに繋ぐとき)

判定基準:
  font-size  < 14px      … 本文として小さい(規格ではなく運用上の取り決め)
  タップ標的  < 48px      … Material 48dp / WCAG 2.5.5 AAA 44px を両方満たす値として 48
  font-weight 800 / 900   … サイズでなく太さで見出しを作っているサイン
"""
import re, sys, os, json, collections

FS_MIN, TAP_MIN = 14.0, 48.0
EXT = (".html", ".htm", ".css", ".scss", ".vue", ".jsx", ".tsx")

# CSS の font-size:12px と Tailwind 任意値 text-[12px] の両方を拾う
RE_FS   = re.compile(r"font-size\s*:\s*([\d.]+)px")
RE_TWFS = re.compile(r"text-\[([\d.]+)px\]")
RE_TAP  = re.compile(r"(?:min-height|height)\s*:\s*([\d.]+)px")
RE_FW   = re.compile(r"font-weight\s*:\s*(\d{3})")
RE_RAD  = re.compile(r"border-radius\s*:\s*([\d.]+)px")
# タップ標的っぽいセレクタを含むブロックだけに絞るための粗いブロック抽出
RE_BLOCK = re.compile(r"([^{}]+)\{([^{}]*)\}", re.S)
TAPPY = ("button", "btn", ".tap", "a:", "[role=\"button\"]", "input", "select", ".choice", ".opt")

def walk(paths):
    for p in paths:
        if os.path.isfile(p):
            yield p
        else:
            for root, _, files in os.walk(p):
                if any(x in root for x in ("node_modules", ".git", "dist", "build", "vendor")):
                    continue
                for f in files:
                    if f.endswith(EXT):
                        yield os.path.join(root, f)

def scan(paths):
    fs, taps, fw, rad = [], [], collections.Counter(), collections.Counter()
    worst, nfiles = [], 0
    for path in walk(paths):
        try:
            src = open(path, encoding="utf-8", errors="replace").read()
        except OSError:
            continue
        nfiles += 1
        sizes = [float(x) for x in RE_FS.findall(src)] + [float(x) for x in RE_TWFS.findall(src)]
        fs += sizes
        if sizes and min(sizes) < FS_MIN:
            worst.append((min(sizes), path))
        for w in RE_FW.findall(src):
            fw[int(w)] += 1
        for r in RE_RAD.findall(src):
            rad[float(r)] += 1
        for sel, body in RE_BLOCK.findall(src):
            if not any(t in sel.lower() for t in TAPPY):
                continue
            for h in RE_TAP.findall(body):
                taps.append(float(h))
    return dict(files=nfiles, fs=fs, taps=taps, fw=fw, rad=rad, worst=sorted(worst)[:10])

def pct(part, whole):
    return round(100.0 * part / whole, 1) if whole else 0.0

def main():
    args = [a for a in sys.argv[1:] if not a.startswith("--")]
    as_json = "--json" in sys.argv
    if not args:
        print(__doc__); sys.exit(2)
    r = scan(args)
    fs, taps = r["fs"], r["taps"]
    small = [x for x in fs if x < FS_MIN]
    tiny_tap = [x for x in taps if x < TAP_MIN]
    heavy = sum(v for k, v in r["fw"].items() if k >= 800)
    out = {
        "files": r["files"],
        "font_size_decls": len(fs),
        "font_size_under_14px": len(small),
        "font_size_under_14px_pct": pct(len(small), len(fs)),
        "font_size_min": min(fs) if fs else None,
        "tap_decls": len(taps),
        "tap_under_48px": len(tiny_tap),
        "tap_under_48px_pct": pct(len(tiny_tap), len(taps)),
        "font_weight_800_900": heavy,
        "radius_distinct_values": sorted(r["rad"]),
        "worst_files": [{"min_px": m, "path": p} for m, p in r["worst"]],
    }
    if as_json:
        print(json.dumps(out, ensure_ascii=False, indent=2)); return
    print(f"走査ファイル数            : {out['files']}")
    print(f"font-size 宣言            : {out['font_size_decls']}")
    print(f"  うち 14px 未満          : {out['font_size_under_14px']}  ({out['font_size_under_14px_pct']}%)  最小 {out['font_size_min']}px")
    print(f"タップ標的の高さ宣言       : {out['tap_decls']}")
    print(f"  うち 48px 未満          : {out['tap_under_48px']}  ({out['tap_under_48px_pct']}%)")
    print(f"font-weight 800/900       : {out['font_weight_800_900']}")
    print(f"border-radius の値の種類   : {out['radius_distinct_values']}")
    if out["worst_files"]:
        print("最小フォントが小さいファイル上位:")
        for w in out["worst_files"]:
            print(f"  {w['min_px']:>5}px  {w['path']}")

if __name__ == "__main__":
    main()

設計上、意図的に割り切っていること

  • CSSをパースしていません。 使っているのは reos.walk だけです。正しいパーサを入れれば精度は上がりますが、依存ゼロで即コピペできることを優先しました。
  • RE_BLOCK のブロック抽出は雑です。 ネストした @media や CSS-in-JS では取りこぼします。それでも「ボタンの高さ指定が114件しかない」という一番効いた発見は取れました。
  • Tailwind のスケール は追っていません。 text-xs(0.75rem = 12px)などのユーティリティは px 文字列を含まないので数えられません。任意値 text-[10px] だけが拾えます。
  • 詳細度を無視しています。 上書きされて実際には効いていない宣言も1件として数えます。

最後の点はけっこう大きくて、「50%が14px未満」は「画面の50%が読みにくい」という意味ではありません


この検査で分かること・分からないこと

限界

知りたいこと ソース検査 ブラウザ実測
font-size に何px と書いたか
実際に描画された文字が何pxか ❌(継承・rem・上書きが効く) getComputedStyle
padding だけで決まるボタンの実寸 getBoundingClientRect
375px幅で横スクロールが出るか scrollWidth > clientWidth
値が何種類に散っているか ✅(むしろソース向き)

ソース検査は「見え方」ではなく「規律」を測る道具

ここを取り違えると危ないです。このスクリプトの出力はUXの評価ではありません。

ただ逆に、規律の欠如はソースにしか現れません。描画結果だけ見ていても「border-radius が25種類ある」ことには一生気づけません。だから2つは代替関係ではなく、役割が違う併用ツールとして持つのが正しいと思っています。

実寸が要るときは1行で足りる

描画側は自前で書く必要はほとんどなくて、DevTools のコンソールに投げるだけで取れます。

// 実際に描画された文字サイズの分布
[...document.querySelectorAll('*')]
  .filter(e => e.children.length === 0 && e.textContent.trim())
  .map(e => parseFloat(getComputedStyle(e).fontSize))
  .reduce((a, v) => (a[v] = (a[v] || 0) + 1, a), {});
// 実寸44px未満のクリック可能要素
[...document.querySelectorAll('a,button,input,select,[role="button"]')]
  .map(e => ({ el: e, r: e.getBoundingClientRect() }))
  .filter(o => o.r.height > 0 && (o.r.height < 44 || o.r.width < 44));

ソース検査で当たりを付けて、怪しいページだけこれを流す、という順番にすると手数が減ります。

CI に入れるなら「全体」ではなく「差分」で止める

現状50%のリポジトリで「14px未満をゼロにする」ゲートを入れても、初日から全ビルドが落ちるだけで意味がありません。

現実的なのはこの2段です。

  1. --jsonfont_size_under_14px を毎回記録して、前回より増えたら落とす(悪化だけ止める)
  2. radius_distinct_values要素数が増えたら落とす(新しい角丸を勝手に増やさせない)

「今の値をベースラインにして、悪化だけ止める」 なら初日から入れられます。


直すときに実際にやったこと

まず「決めた値」を文章で1枚に書いた

コードを直す前に、font-size の下限、タップ標的の下限、border-radius の許容値、色の役割を、px単位の実体値で1ファイルに書きました

これはAIに投げるためです。「見やすくして」と頼むと毎回違う答えが返ってきますが、「本文17px / 行間1.85 / 角丸は 4,8,12,16,24,9999 のいずれか」と渡すと、返ってくるものが安定します。指示に基準が入っていないことが原因だったので、基準を持たせるのが直接の対策になります。

次に「収益に効く面」から順に直した

1,200件を一律に直す代わりに、アクセスが多いページを含むプロジェクト1本だけ先に直しました。そこは 14px未満の割合が下がって、他は「書いた時点のまま」です。全部を直していないので、この記事の 50% という数字はそのまま残っています。

効かなかったこと

  • 一括置換12px14px の機械置換は、意図的に小さくしていた凡例やバッジまで太らせてレイアウトが崩れました。戻しました。
  • 「小さい文字を使わないで」というプロンプト。基準値を書かずに方針だけ渡すと、次に別の値が出てくるだけでした。数字を渡すまで再現しませんでした。

まとめ

  • AI生成のHTML/CSS 168ファイルで、font-size宣言の50.0%が14px未満、最小7px(2026-09-04 実測)
  • 同じ人・同じ環境でも、プロジェクト間で 0%〜69% に割れた。差は技術ではなく基準を渡したかどうかだった
  • WCAG に最小フォントサイズの達成基準は無い。数字があるのは Lighthouse の12px(40%ルール)と、タップ標的の 24 / 44 / 48px
  • ソース検査は見え方ではなく規律を測る。実寸は getComputedStyle / getBoundingClientRect で別途取る
  • CIに入れるなら絶対値でなく「前回比の悪化」で止めると初日から動く

数えるまでは「気をつける」しか対策がありませんでした。数えたら、基準を1枚のファイルに書いてAIに渡すという具体的な手が出てきました。100行ちょっとの正規表現でここまで分かるので、AIにフロントを書かせている人は一度自分のリポジトリに向けてみると何か出てくると思います。


この8プロジェクトをAIエージェントに作らせて運用している側の話(何を任せて、どこで人間が詰まったか)は、note に実データつきでまとめています(有料記事です)。この記事の内容だけで完結するので、興味があればどうぞ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?