From af941147f1f4c302bb914f6f9567557cbf5fc097 Mon Sep 17 00:00:00 2001 From: tkc310 Date: Tue, 20 Oct 2026 13:08:13 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(slides):=20=E3=83=87=E3=82=B6=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=82=B7=E3=82=B9=E3=83=86=E3=83=A0=E3=81=AB=E9=80=A3?= =?UTF-8?q?=E5=8B=95=E3=81=97=E3=81=9F=E3=82=B9=E3=83=A9=E3=82=A4=E3=83=89?= =?UTF-8?q?=E3=82=92=E4=BD=9C=E3=82=8B=E3=82=B9=E3=82=AD=E3=83=AB=20ds-sli?= =?UTF-8?q?des=20=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8341-ui の semantic トークンと design-principles の原則で、発表用のスライド(1 ファイルの HTML デッキ)を作れるようにした。 - スキル `.claude/skills/ds-slides/`(前提のヒアリング → 構成 → レイアウト選択 → 検査とビルド → 目視確認) - ルール `docs/rulebook/07-slides.md`(S1〜S20。原則と WCAG をスライドに落とした対応表、レイアウト、要素とトークンの対応) - `scripts/slides/`: build.ts(トークンを Resolver で解決して埋め込む。依存ゼロ)、check.ts(ソースの検査と deck.css の監査)、deck.css / deck.js(発表・一覧モード、キーボード操作、印刷で PDF)、見本 - `make slides` / `make slides-check`。`make check` で deck.css と見本を検査 - ADR-0017、改善ログ Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Yb44feYKDNj2ukQeT2j8Ks --- .claude/skills/ds-slides/SKILL.md | 130 +++++ AGENTS.md | 3 + Makefile | 14 +- docs/ai-agents.md | 1 + docs/decisions/0017-slides.md | 27 + docs/decisions/README.md | 1 + docs/improvement/log.md | 1 + docs/rulebook/07-slides.md | 101 ++++ docs/rulebook/README.md | 1 + package.json | 2 +- scripts/slides/build.ts | 245 ++++++++ scripts/slides/check.ts | 50 ++ scripts/slides/deck.css | 830 ++++++++++++++++++++++++++++ scripts/slides/deck.js | 172 ++++++ scripts/slides/examples/sample.html | 139 +++++ scripts/slides/lib.ts | 687 +++++++++++++++++++++++ 16 files changed, 2402 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/ds-slides/SKILL.md create mode 100644 docs/decisions/0017-slides.md create mode 100644 docs/rulebook/07-slides.md create mode 100644 scripts/slides/build.ts create mode 100644 scripts/slides/check.ts create mode 100644 scripts/slides/deck.css create mode 100644 scripts/slides/deck.js create mode 100644 scripts/slides/examples/sample.html create mode 100644 scripts/slides/lib.ts diff --git a/.claude/skills/ds-slides/SKILL.md b/.claude/skills/ds-slides/SKILL.md new file mode 100644 index 000000000..a8086f8b8 --- /dev/null +++ b/.claude/skills/ds-slides/SKILL.md @@ -0,0 +1,130 @@ +--- +name: ds-slides +description: 8341-ui のトークンとデザイン原則に沿って、発表用のスライド(1 ファイルの HTML デッキ。印刷すると PDF)を作る・直すときに使う。構成の決め方、レイアウトの選び方、検査とビルドの手順を含む。 +--- + +# デザインシステムに沿ってスライドを作る + +スライドの見た目(文字サイズ・色・余白・揃え)は `scripts/slides/deck.css` が 8341-ui の semantic トークンだけで決める。作者が決めるのは **構成・文言・レイアウトの選択** だけにする。ルールの正は `docs/rulebook/07-slides.md`(S1〜S20)で、その元は `docs/design-principles/principles.md` と WCAG 2.2 である。 + +## 作る前に読む + +1. `docs/rulebook/07-slides.md`(ルール・レイアウト・使えるクラス) +2. `docs/rulebook/05-writing.md`(です・ます、数字、リンクの書き方) +3. `scripts/slides/examples/sample.html`(全レイアウトの見本) +4. 判断に迷ったら `docs/design-principles/principles.md` の該当ルールだけ(「読み順と優先度」「まとまり」「一度に出す数」「削る」「文言」) + +## 手順 + +### 1. 前提を聞く + +推測できない項目だけを、短いリストで一度に聞く(Claude Code では AskUserQuestion、ほかのエージェントでは番号付きの質問リスト)。 + +- 目的: 聞き手に何を決めて・知ってもらいたいか(1 文で) +- 聞き手: 誰か、前提知識はどれくらいか +- 場: 発表時間(枚数の目安は 1 分 1 枚)、投影か配布か、会場が暗いか(暗いならダークを提案) +- 材料: 数字・図・出典として使ってよい資料 + +答えを 3〜5 行で要約して確認してから進む。 + +### 2. 構成を決める(書く前に) + +- まず **結論の 1 文** を決める。冒頭(表紙の次)に `statement` で置く(S7) +- 各スライドの見出し(`h2`)だけを並べた骨子を作る。見出しは **そのスライドの結論を文で**(S1)。見出しだけを順に読んで話が通じれば構成は良い +- 章が 3 つ以上なら `section`(章扉)で区切る。最後は `summary` で「持ち帰ってほしいこと」を 3 つまで +- 骨子をユーザーに見せて合意してから中身を書く(枚数が多い、または聞き手が重要な場合) + +### 3. レイアウトを選ぶ + +| 伝えたいこと | レイアウト | +| --- | --- | +| 1 文の主張・結論 | `statement` | +| 理由や事実を 3〜5 個 | `content`(箇条書き) | +| 2〜3 個の対比・並列 | `columns`(枠が要るなら `.col.card`) | +| 数字そのものが主役 | `stats`(2〜4 個) | +| 時間の流れ・手順 | `steps`(3〜5 段) | +| 推移・比較の傾向 | `figure`(SVG のグラフ) | +| 数値を正確に読ませる | `table`(6 行 × 5 列まで) | + +同じ種類の内容には、デッキの中で同じレイアウトを使う(一貫性)。 + +### 4. ソースを書く + +`slides/<名前>.html`(置き場所はユーザーの指定に従う)に、見本と同じ形で書く。 + +```html + + +デッキの題 + +
+

デッキの題

+

副題

+

2026年10月20日 発表者

+
+
+

このスライドの結論を文で書く

+
  • …
+

出典: …

+ +
+ + +``` + +決まり(違反は検査でエラーになる): + +- `body` の直下は `section.slide` だけ。`style` 属性・` + + + +
+

${escapeHtml(title)}

+ + + + + +
+
+${slidesHtml} +
+

+ + + +`; + + const outFile = values.out + ? resolve(process.cwd(), values.out) + : resolve(REPO_ROOT, 'dist/slides', `${basename(file, '.html')}.html`); + mkdirSync(dirname(outFile), { recursive: true }); + writeFileSync(outFile, out); + const warnings = issues.length - errors.length; + console.log( + `✔ ${relative(process.cwd(), outFile)}(${total} 枚、テーマ ${theme}${warnings ? `、警告 ${warnings} 件` : ''})`, + ); + // 見出しの一覧(構成の確認用) + deck.slides.forEach((s, i) => { + const h = find(s, (e) => e.tag === 'h1' || e.tag === 'h2')[0]; + console.log(` ${String(i + 1).padStart(2)}. [${attr(s, 'data-layout')}] ${h ? compact(textOf(h)) : ''}`); + }); +} + +main(); diff --git a/scripts/slides/check.ts b/scripts/slides/check.ts new file mode 100644 index 000000000..659a41d96 --- /dev/null +++ b/scripts/slides/check.ts @@ -0,0 +1,50 @@ +/** + * スライドのソースを docs/rulebook/07-slides.md のルールで検査する。 + * + * 実行: make slides-check SRC=slides/foo.html(複数可) + * 引数なし … deck.css の監査と、examples/ のサンプルの検査(make check から呼ぶ) + * + * error(終了コード 1): 必須ルールの違反。warning: 推奨ルールからの外れ(分量・強調の数など) + */ +import { readFileSync, readdirSync } from 'node:fs'; +import { relative, resolve } from 'node:path'; +import { SLIDES_DIR, checkCss, checkDeck, formatIssues, readDeck, tokenCss } from './lib.ts'; +import type { Issue } from './lib.ts'; + +const cwd = process.cwd(); +const tokens = tokenCss(); +const results: { file: string; issues: Issue[] }[] = []; + +const cssFile = resolve(SLIDES_DIR, 'deck.css'); +results.push({ file: cssFile, issues: checkCss(readFileSync(cssFile, 'utf8'), tokens.names) }); + +const args = process.argv.slice(2); +const files = args.length + ? args.map((a) => resolve(cwd, a)) + : readdirSync(resolve(SLIDES_DIR, 'examples')) + .filter((f) => f.endsWith('.html')) + .map((f) => resolve(SLIDES_DIR, 'examples', f)); + +for (const file of files) { + try { + results.push({ file, issues: checkDeck(readDeck(file)) }); + } catch (e) { + const err = e as Error & { line?: number }; + results.push({ file, issues: [{ id: 'parse', level: 'error', line: err.line, message: err.message }] }); + } +} + +let errors = 0; +let warnings = 0; +for (const { file, issues } of results) { + const rel = relative(cwd, file); + errors += issues.filter((i) => i.level === 'error').length; + warnings += issues.filter((i) => i.level === 'warning').length; + if (issues.length) console.log(`${rel}\n${formatIssues(rel, issues)}\n`); +} +console.log( + errors + ? `✖ スライドの検査: エラー ${errors} 件、警告 ${warnings} 件` + : `✔ スライドの検査: ${files.length} ファイルと deck.css(警告 ${warnings} 件)`, +); +process.exit(errors ? 1 : 0); diff --git a/scripts/slides/deck.css b/scripts/slides/deck.css new file mode 100644 index 000000000..0fefe37f4 --- /dev/null +++ b/scripts/slides/deck.css @@ -0,0 +1,830 @@ +/* + * 8341-ui のスライド(HTML のデッキ)の見た目。ルールは docs/rulebook/07-slides.md。 + * 値はすべて semantic トークン(var(--8341-*))と、デッキの寸法(--deck-*)だけで書く。 + * make slides-check がこのファイルを監査する(色・px・太さ・中央揃え・動き・グラデーションの直書きを禁止)。 + * + * モード(html[data-mode]) + * present … 960×540(16:9)のキャンバスを画面に合わせて拡大・縮小し、1 枚ずつ見せる(deck.js が付ける) + * list … 1 枚ずつ縦に並べる。JavaScript が無いとき・幅 768px 未満・印刷はこちら(S17) + */ + +:root { + /* 論理サイズ 960×540。rem なので、利用者の文字サイズ設定ごと全体が拡大・縮小する */ + --deck-width: 60rem; + --deck-height: 33.75rem; + --deck-scale: 1; + --deck-bar-height: calc(var(--8341-size-control-md) + var(--8341-space-inset-sm) * 2); +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +body { + margin: 0; + background-color: var(--8341-color-bg-subtle); + color: var(--8341-color-text-default); + font-family: var(--8341-text-body-md-font-family); + font-size: var(--8341-text-body-md-font-size); + font-weight: var(--8341-text-body-md-font-weight); + line-height: var(--8341-text-body-md-line-height); + letter-spacing: var(--8341-text-body-md-letter-spacing); + line-break: strict; + overflow-wrap: anywhere; +} + +/* --------------------------------------------------------------------------- + * 操作バー(S16・S20) + * ------------------------------------------------------------------------- */ + +.deck-bar { + display: flex; + align-items: center; + gap: var(--8341-space-gap-sm); + min-height: var(--deck-bar-height); + padding: var(--8341-space-inset-sm) var(--8341-space-page-inline); + border-bottom: var(--8341-border-width-default) solid var(--8341-color-border-subtle); + background-color: var(--8341-color-bg-surface); +} + +.deck-bar-title { + margin: 0 auto 0 0; + min-width: 0; + font-size: var(--8341-text-label-md-font-size); + font-weight: var(--8341-text-label-md-font-weight); + line-height: var(--8341-text-dense-md-line-height); + letter-spacing: var(--8341-text-label-md-letter-spacing); +} + +.deck-counter { + min-width: 4em; + color: var(--8341-color-text-subtle); + font-size: var(--8341-text-body-sm-font-size); + font-variant-numeric: tabular-nums; +} + +.deck-button { + display: inline-flex; + align-items: center; + gap: var(--8341-space-gap-xs); + min-height: var(--8341-size-target-min); + min-width: var(--8341-size-target-min); + padding: 0 var(--8341-space-inset-md); + border: var(--8341-border-width-default) solid var(--8341-color-border-input); + border-radius: var(--8341-radius-md); + background-color: var(--8341-color-bg-surface); + color: var(--8341-color-text-default); + font: inherit; + font-size: var(--8341-text-label-sm-font-size); + font-weight: var(--8341-text-label-sm-font-weight); + line-height: var(--8341-text-label-sm-line-height); + letter-spacing: var(--8341-text-label-sm-letter-spacing); + white-space: nowrap; + cursor: pointer; +} + +.deck-button svg { + width: var(--8341-size-icon-md); + height: var(--8341-size-icon-md); + flex-shrink: 0; +} + +@media (hover: hover) { + .deck-button:not([aria-disabled='true']):hover { + background-color: var(--8341-color-bg-muted); + border-color: var(--8341-color-border-strong); + } +} + +.deck-button[aria-disabled='true'] { + border-color: var(--8341-color-border-disabled); + background-color: var(--8341-color-bg-disabled); + color: var(--8341-color-text-disabled); + cursor: not-allowed; +} + +.deck-button[hidden] { + display: none; +} + +/* フォーカスは focus-ring と同じ二重リング(A5) */ +:where(.deck-button, .slide a):focus-visible { + outline: var(--8341-focus-outline-width) solid var(--8341-color-focus-outline); + outline-offset: var(--8341-focus-offset); + box-shadow: 0 0 0 var(--8341-focus-ring-width) var(--8341-color-focus-ring); +} + +/* --------------------------------------------------------------------------- + * 一覧モード(既定。S17) + * ------------------------------------------------------------------------- */ + +.deck { + display: flex; + flex-direction: column; + gap: var(--8341-space-stack-md); + max-width: calc(var(--deck-width) + var(--8341-space-page-inline) * 2); + margin: 0 auto; + padding: var(--8341-space-stack-md) var(--8341-space-page-inline) var(--8341-space-stack-lg); +} + +.slide { + position: relative; + display: flex; + flex-direction: column; + gap: var(--8341-space-stack-md); + width: 100%; + padding: var(--8341-space-stack-md) var(--8341-space-page-inline); + border: var(--8341-border-width-default) solid var(--8341-color-border-default); + border-radius: var(--8341-radius-lg); + background-color: var(--8341-color-bg-canvas); + color: var(--8341-color-text-default); + font-size: var(--8341-text-body-lg-font-size); + line-height: var(--8341-text-body-lg-line-height); + letter-spacing: var(--8341-text-body-lg-letter-spacing); +} + +/* 発表者メモ(一覧モードでだけ見せる) */ +.deck-notes { + margin-top: calc(var(--8341-space-stack-sm) - var(--8341-space-stack-md)); + padding: var(--8341-space-inset-lg); + border-radius: var(--8341-radius-lg); + background-color: var(--8341-color-bg-muted); + font-size: var(--8341-text-body-md-font-size); +} + +.deck-notes-label { + margin: 0 0 var(--8341-space-stack-sm); + color: var(--8341-color-text-subtle); + font-size: var(--8341-text-label-sm-font-size); + font-weight: var(--8341-text-label-sm-font-weight); + line-height: var(--8341-text-label-sm-line-height); +} + +.deck-notes > :last-child { + margin-bottom: 0; +} + +@media (min-width: 768px) { + .deck-bar { + padding-inline: var(--8341-space-gutter); + } + + .slide { + min-height: var(--deck-height); + padding: var(--8341-space-gutter) var(--8341-space-stack-lg); + } +} + +/* --------------------------------------------------------------------------- + * 発表モード + * ------------------------------------------------------------------------- */ + +html[data-mode='present'] body { + overflow: hidden; +} + +html[data-mode='present'] .deck-bar { + position: fixed; + inset: auto 0 0; + border-top: var(--8341-border-width-default) solid var(--8341-color-border-subtle); + border-bottom: 0; +} + +html[data-mode='present'] .deck { + position: fixed; + inset: 0 0 var(--deck-bar-height); + display: grid; + place-items: center; + max-width: none; + margin: 0; + padding: 0; + overflow: hidden; +} + +html[data-mode='present'] .slide { + grid-area: 1 / 1; + flex-shrink: 0; + width: var(--deck-width); + height: var(--deck-height); + min-height: 0; + padding: var(--8341-space-gutter) var(--8341-space-stack-lg); + overflow: hidden; + border: 0; + border-radius: 0; + transform: scale(var(--deck-scale)); +} + +html[data-mode='present'] .slide[hidden], +html[data-mode='present'] .deck-notes { + display: none; +} + +/* 全画面では操作バーを隠し、キーボードで触れたとき・マウスを載せたときだけ出す */ +html[data-mode='present']:fullscreen .deck { + inset: 0; +} + +html[data-mode='present']:fullscreen .deck-bar:not(:focus-within, :hover) { + opacity: 0; +} + +/* --------------------------------------------------------------------------- + * スライドの中身(共通) + * ------------------------------------------------------------------------- */ + +.slide :where(h1, h2, h3, p, ul, ol, figure, table, blockquote) { + margin: 0; +} + +.slide :where(h1, h2, h3) { + font-feature-settings: 'palt'; +} + +.slide h2 { + font-size: var(--8341-text-heading-lg-font-size); + font-weight: var(--8341-text-heading-lg-font-weight); + line-height: var(--8341-text-heading-lg-line-height); + letter-spacing: var(--8341-text-heading-lg-letter-spacing); +} + +.slide h3 { + font-size: var(--8341-text-heading-xs-font-size); + font-weight: var(--8341-text-heading-xs-font-weight); + line-height: var(--8341-text-heading-xs-line-height); + letter-spacing: var(--8341-text-heading-xs-letter-spacing); +} + +.slide strong { + font-weight: var(--8341-text-label-md-font-weight); +} + +.slide :where(ul, ol):not(.steps) { + display: flex; + flex-direction: column; + gap: var(--8341-space-stack-sm); + padding-inline-start: 1.5em; +} + +.slide :where(ul, ol):not(.steps) :where(ul, ol) { + margin-top: var(--8341-space-stack-sm); + font-size: var(--8341-text-body-md-font-size); + line-height: var(--8341-text-body-md-line-height); +} + +.slide li::marker { + color: var(--8341-color-icon-subtle); +} + +.slide ol > li::marker { + color: var(--8341-color-text-subtle); + font-weight: var(--8341-text-label-md-font-weight); +} + +.slide :where(code, kbd, samp) { + font-family: var(--8341-text-mono-md-font-family); + font-size: 0.9em; +} + +.slide .lead { + color: var(--8341-color-text-subtle); + font-size: var(--8341-text-heading-xxs-font-size); + line-height: var(--8341-text-heading-xxs-line-height); +} + +.slide .source { + margin-top: auto; + color: var(--8341-color-text-subtle); + font-size: var(--8341-text-body-sm-font-size); + line-height: var(--8341-text-body-sm-line-height); +} + +.slide .meta { + color: var(--8341-color-text-subtle); + font-size: var(--8341-text-body-md-font-size); + line-height: var(--8341-text-body-md-line-height); +} + +.slide .eyebrow { + color: var(--8341-color-text-key); + font-size: var(--8341-text-label-md-font-size); + font-weight: var(--8341-text-label-md-font-weight); + line-height: var(--8341-text-label-md-line-height); + letter-spacing: var(--8341-text-label-md-letter-spacing); +} + +/* リンク(Link と同じ 5 状態。下線は常に表示) */ +.slide a { + border-radius: var(--8341-radius-xs); + color: var(--8341-color-text-link); + text-decoration-line: underline; + text-decoration-thickness: var(--8341-border-width-underline); + text-underline-offset: 0.2em; +} + +.slide a:visited { + color: var(--8341-color-text-link-visited); +} + +@media (hover: hover) { + .slide a:hover { + color: var(--8341-color-text-link-hover); + text-decoration-thickness: var(--8341-border-width-underline-hover); + } +} + +.slide a:active { + color: var(--8341-color-text-link-active); +} + +.slide a:focus-visible { + background-color: var(--8341-color-focus-ring); + color: var(--8341-color-focus-text); + box-shadow: none; +} + +.slide a svg { + width: 1em; + height: 1em; + margin-inline-start: 0.2em; + vertical-align: -0.125em; +} + +/* ページ番号(ビルドが付ける) */ +.deck-footer { + display: flex; + gap: var(--8341-space-gap-md); + justify-content: space-between; + margin-top: auto; + color: var(--8341-color-text-subtle); + font-size: var(--8341-text-body-sm-font-size); + line-height: var(--8341-text-dense-sm-line-height); +} + +.slide .source + .deck-footer { + margin-top: calc(var(--8341-space-stack-sm) - var(--8341-space-stack-md)); +} + +.deck-footer-page { + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +/* --------------------------------------------------------------------------- + * レイアウト + * ------------------------------------------------------------------------- */ + +/* 表紙 */ +.slide[data-layout='title'] { + gap: var(--8341-space-stack-sm); +} + +.slide[data-layout='title'] h1 { + margin-bottom: var(--8341-space-stack-sm); + font-size: var(--8341-text-display-md-font-size); + font-weight: var(--8341-text-display-md-font-weight); + line-height: var(--8341-text-display-md-line-height); + letter-spacing: var(--8341-text-display-md-letter-spacing); +} + +/* 見出しのバー(border-width.accent × border.key) */ +.slide[data-layout='title'] h1::before, +.slide[data-layout='section'] h2::before { + content: ''; + display: block; + width: var(--8341-space-stack-lg); + height: var(--8341-border-width-accent); + margin-bottom: var(--8341-space-stack-md); + background-color: var(--8341-color-border-key); +} + +.slide[data-layout='title'] .subtitle { + color: var(--8341-color-text-subtle); + font-size: var(--8341-text-heading-xs-font-size); + font-weight: var(--8341-text-body-lg-font-weight); + line-height: var(--8341-text-heading-xs-line-height); +} + +.slide[data-layout='title'] .meta { + margin-top: var(--8341-space-stack-md); +} + +/* 表紙・章扉・1 文で言い切る: 中身を上下の中央に置く(ページ番号は下端のまま) */ +.slide:where([data-layout='title'], [data-layout='section'], [data-layout='statement']) > :first-child { + margin-top: auto; +} + +.slide[data-layout='title'] > :last-child { + margin-bottom: auto; +} + +.slide[data-layout='section'] { + gap: var(--8341-space-stack-sm); +} + +.slide:where([data-layout='section'], [data-layout='statement']) h2 { + font-size: var(--8341-text-display-md-font-size); + font-weight: var(--8341-text-display-md-font-weight); + line-height: var(--8341-text-display-md-line-height); + letter-spacing: var(--8341-text-display-md-letter-spacing); +} + +.slide[data-layout='statement'] > p { + max-width: var(--8341-size-prose-max); + color: var(--8341-color-text-subtle); +} + +/* 列(ビルドが .col を .deck-cols で包む) */ +.deck-cols { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + gap: var(--8341-space-gutter); +} + +.slide .col { + display: flex; + flex-direction: column; + gap: var(--8341-space-stack-sm); +} + +.slide .card { + padding: var(--8341-space-inset-xl); + border: var(--8341-border-width-default) solid var(--8341-color-border-default); + border-radius: var(--8341-radius-xl); + background-color: var(--8341-color-bg-surface); +} + +/* 数字(ビルドが .stat を .deck-stats で包む) */ +.deck-stats { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + gap: var(--8341-space-gutter); + margin-block: auto; +} + +.slide .stat { + display: flex; + flex-direction: column; + gap: var(--8341-space-stack-sm); + padding-top: var(--8341-space-stack-sm); + border-top: var(--8341-border-width-accent) solid var(--8341-color-border-key); +} + +.slide .stat-value { + color: var(--8341-color-text-key); + font-size: var(--8341-text-display-lg-font-size); + font-weight: var(--8341-text-display-lg-font-weight); + line-height: var(--8341-text-display-lg-line-height); + letter-spacing: var(--8341-text-display-lg-letter-spacing); + font-variant-numeric: tabular-nums; +} + +.slide .stat-label { + font-size: var(--8341-text-body-md-font-size); + line-height: var(--8341-text-body-md-line-height); +} + +/* 手順 */ +.slide ol.steps { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + gap: var(--8341-space-gutter); + margin-block: auto; + padding: 0; + list-style: none; + counter-reset: deck-step; +} + +.slide ol.steps > li { + display: flex; + flex-direction: column; + gap: var(--8341-space-stack-sm); + padding-top: var(--8341-space-stack-sm); + border-top: var(--8341-border-width-accent) solid var(--8341-color-border-key); + counter-increment: deck-step; + font-size: var(--8341-text-body-md-font-size); + line-height: var(--8341-text-body-md-line-height); +} + +/* 番号は ol が読み上げるので、見た目だけ(代替テキストは空) */ +.slide ol.steps > li::before { + content: counter(deck-step) / ''; + color: var(--8341-color-text-key); + font-size: var(--8341-text-heading-md-font-size); + font-weight: var(--8341-text-heading-md-font-weight); + line-height: var(--8341-text-label-md-line-height); + font-variant-numeric: tabular-nums; +} + +/* 図 */ +.slide[data-layout='figure'] figure { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--8341-space-stack-sm); + min-height: 0; +} + +.slide figure :where(img, svg) { + display: block; + flex: 1; + width: 100%; + min-height: 0; + max-height: 100%; + object-fit: contain; +} + +.slide figcaption { + color: var(--8341-color-text-subtle); + font-size: var(--8341-text-body-sm-font-size); + line-height: var(--8341-text-body-sm-line-height); +} + +/* グラフの色(S11・S12)。注目する系列だけキーカラー、ほかは 3:1 の灰色。色だけに頼らずラベルを添える */ +.slide svg .chart-key { + fill: var(--8341-color-icon-key); +} + +.slide svg .chart-base { + fill: var(--8341-color-border-default); +} + +.slide svg :where(line, polyline, path).chart-key { + fill: none; + stroke: var(--8341-color-icon-key); + stroke-width: var(--8341-border-width-accent); +} + +.slide svg :where(line, polyline, path).chart-base { + fill: none; + stroke: var(--8341-color-border-default); + stroke-width: var(--8341-border-width-strong); +} + +.slide svg .chart-axis { + fill: none; + stroke: var(--8341-color-border-default); + stroke-width: var(--8341-border-width-default); +} + +.slide svg :where(.chart-label, .chart-label-subtle) { + font-family: var(--8341-text-body-sm-font-family); + font-size: var(--8341-text-body-sm-font-size); +} + +.slide svg .chart-label { + fill: var(--8341-color-text-default); +} + +.slide svg .chart-label-subtle { + fill: var(--8341-color-text-subtle); +} + +/* 表 */ +.slide table { + width: 100%; + border-collapse: collapse; + font-size: var(--8341-text-body-md-font-size); + line-height: var(--8341-text-body-md-line-height); +} + +.slide :where(th, td) { + padding: var(--8341-space-inset-sm) var(--8341-space-inset-md); + border-bottom: var(--8341-border-width-default) solid var(--8341-color-border-default); + text-align: start; + vertical-align: top; +} + +.slide th { + font-weight: var(--8341-text-label-md-font-weight); +} + +.slide thead th { + background-color: var(--8341-color-bg-muted); +} + +.slide :where(th, td).num { + text-align: end; + font-variant-numeric: tabular-nums; +} + +/* 注記(NotificationBanner と同じ見た目。ビルドがアイコンと種別名を足す) */ +.slide .callout { + display: flex; + align-items: flex-start; + gap: var(--8341-space-gap-md); + padding: var(--8341-space-inset-lg); + padding-inline-start: calc(var(--8341-space-inset-lg) + var(--8341-border-width-accent)); + border: var(--8341-border-width-default) solid var(--deck-callout-accent); + border-radius: var(--8341-radius-lg); + box-shadow: inset var(--8341-border-width-accent) 0 0 0 var(--deck-callout-accent); + background-color: var(--deck-callout-bg); + font-size: var(--8341-text-body-md-font-size); + line-height: var(--8341-text-body-md-line-height); +} + +.slide .callout > .deck-callout-icon { + flex-shrink: 0; + width: var(--8341-size-icon-lg); + height: var(--8341-size-icon-lg); + color: var(--deck-callout-icon); +} + +.slide .callout > .deck-callout-body { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--8341-space-stack-sm); + min-width: 0; +} + +.slide .callout h3 { + font-size: var(--8341-text-heading-xxs-font-size); + line-height: var(--8341-text-heading-xxs-line-height); +} + +.slide .callout, +.slide .callout-info { + --deck-callout-accent: var(--8341-color-border-key); + --deck-callout-bg: var(--8341-color-bg-info-subtle); + --deck-callout-icon: var(--8341-color-icon-info); +} + +.slide .callout-success { + --deck-callout-accent: var(--8341-color-border-success); + --deck-callout-bg: var(--8341-color-bg-success-subtle); + --deck-callout-icon: var(--8341-color-icon-success); +} + +.slide .callout-warning { + --deck-callout-accent: var(--8341-color-border-warning); + --deck-callout-bg: var(--8341-color-bg-warning-subtle); + --deck-callout-icon: var(--8341-color-icon-warning); +} + +.slide .callout-danger { + --deck-callout-accent: var(--8341-color-border-danger); + --deck-callout-bg: var(--8341-color-bg-danger-subtle); + --deck-callout-icon: var(--8341-color-icon-danger); +} + +/* タグ(Tag と同じ) */ +.slide .tag { + display: inline-flex; + align-items: center; + padding: var(--8341-space-inset-xs) var(--8341-space-inset-sm); + border: var(--8341-border-width-default) solid var(--8341-color-border-default); + border-radius: var(--8341-radius-xs); + background-color: var(--8341-color-bg-subtle); + color: var(--8341-color-text-default); + font-size: var(--8341-text-label-sm-font-size); + font-weight: var(--8341-text-label-sm-font-weight); + line-height: var(--8341-text-label-sm-line-height); + white-space: nowrap; + vertical-align: 0.1em; +} + +.slide .tag-key { + border-color: var(--8341-color-border-key); + background-color: var(--8341-color-bg-info-subtle); + color: var(--8341-color-text-key); +} + +.slide .tag-success { + border-color: var(--8341-color-border-success); + background-color: var(--8341-color-bg-success-subtle); + color: var(--8341-color-text-success); +} + +.slide .tag-warning { + border-color: var(--8341-color-border-warning); + background-color: var(--8341-color-bg-warning-subtle); + color: var(--8341-color-text-warning); +} + +.slide .tag-danger { + border-color: var(--8341-color-border-danger); + background-color: var(--8341-color-bg-danger-subtle); + color: var(--8341-color-text-danger); +} + +/* --------------------------------------------------------------------------- + * 幅 768px 未満(一覧モードだけ。見出しは 1 段小さく、列は縦に並べる) + * ------------------------------------------------------------------------- */ + +@media (max-width: 767.98px) { + .slide[data-layout='title'] h1, + .slide:where([data-layout='section'], [data-layout='statement']) h2 { + font-size: var(--8341-text-heading-xl-font-size); + line-height: var(--8341-text-heading-xl-line-height); + letter-spacing: var(--8341-text-heading-xl-letter-spacing); + } + + .slide h2 { + font-size: var(--8341-text-heading-md-font-size); + line-height: var(--8341-text-heading-md-line-height); + } + + .slide .stat-value { + font-size: var(--8341-text-display-md-font-size); + line-height: var(--8341-text-display-md-line-height); + } + + .deck-cols, + .deck-stats, + .slide ol.steps { + grid-auto-flow: row; + gap: var(--8341-space-stack-md); + } + + .slide table { + display: block; + overflow-x: auto; + } +} + +/* --------------------------------------------------------------------------- + * 強制カラー・動き・印刷 + * ------------------------------------------------------------------------- */ + +@media (forced-colors: active) { + :where(.deck-button, .slide a):focus-visible { + outline-color: Highlight; + box-shadow: none; + } + + .slide .callout { + border-inline-start-width: var(--8341-border-width-accent); + } + + .slide[data-layout='title'] h1::before, + .slide[data-layout='section'] h2::before { + background-color: CanvasText; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + scroll-behavior: auto !important; + } +} + +@page { + size: 10in 5.625in; + margin: 0; +} + +@media print { + html, + body { + background-color: var(--8341-color-bg-canvas); + } + + .deck-bar, + .deck-notes { + display: none !important; + } + + html .deck { + position: static; + display: block; + max-width: none; + padding: 0; + } + + html .slide, + html .slide[hidden] { + display: flex !important; + width: var(--deck-width); + height: var(--deck-height); + min-height: 0; + padding: var(--8341-space-gutter) var(--8341-space-stack-lg); + overflow: hidden; + border: 0; + border-radius: 0; + transform: none; + break-after: page; + print-color-adjust: exact; + } +} diff --git a/scripts/slides/deck.js b/scripts/slides/deck.js new file mode 100644 index 000000000..5b2df377c --- /dev/null +++ b/scripts/slides/deck.js @@ -0,0 +1,172 @@ +/* + * 8341-ui のスライド(HTML のデッキ)の操作。ルールは docs/rulebook/07-slides.md(S16〜S18)。 + * ビルド(scripts/slides/build.ts)が HTML に埋め込む。依存なし。 + * + * - 発表モード: 1 枚ずつ表示し、キャンバス(960×540 の論理サイズ)を画面に合わせて拡大・縮小する + * - 一覧モード: 全スライドを縦に並べる。幅 768px 未満では常にこちら(S17) + * - キー: → ↓ PageDown Space で次へ、← ↑ PageUp Shift+Space で前へ、Home / End で最初 / 最後。 + * 文字キー 1 つのショートカットは作らない(WCAG 2.1.4) + * - 切り替えはライブリージョンで「3 / 12 枚目: 見出し」と知らせ、現在以外のスライドは hidden にする(S18) + * - URL の #3 で 3 枚目を開く + */ +(() => { + const root = document.documentElement; + const deck = document.getElementById('deck'); + if (!deck) return; + const slides = Array.from(deck.querySelectorAll(':scope > .slide')); + const total = slides.length; + if (!total) return; + + const $ = (id) => document.getElementById(id); + const status = $('deck-status'); + const counter = $('deck-counter'); + const prev = $('deck-prev'); + const next = $('deck-next'); + const modeButton = $('deck-mode'); + const fullButton = $('deck-fullscreen'); + const narrow = window.matchMedia('(max-width: 767.98px)'); + + let index = 0; + let preferList = false; + + const clamp = (i) => Math.min(Math.max(i, 0), total - 1); + const mode = () => (narrow.matches || preferList ? 'list' : 'present'); + const headingOf = (s) => (s.querySelector('h1, h2')?.textContent ?? '').replace(/\s+/g, ' ').trim(); + const fromHash = () => { + const n = Number.parseInt(location.hash.slice(1), 10); + return Number.isFinite(n) ? clamp(n - 1) : 0; + }; + + function setDisabled(button, disabled) { + // disabled 属性だとフォーカスが外れるため aria-disabled で示し、押しても何もしない + if (!button) return; + if (disabled) button.setAttribute('aria-disabled', 'true'); + else button.removeAttribute('aria-disabled'); + } + + function fit() { + if (mode() !== 'present') return; + const slide = slides[index]; + const scale = Math.min(deck.clientWidth / slide.offsetWidth, deck.clientHeight / slide.offsetHeight); + root.style.setProperty('--deck-scale', String(Math.max(scale, 0.1))); + } + + function warnOverflow() { + const slide = slides[index]; + const over = slide.scrollHeight > slide.clientHeight + 1 || slide.scrollWidth > slide.clientWidth + 1; + slide.toggleAttribute('data-overflow', over); + if (over) + console.warn(`スライド ${index + 1} の内容がキャンバスからはみ出しています。分量を減らすか分けてください(S4)`); + } + + function render({ announce = false, scroll = false } = {}) { + const m = mode(); + root.dataset.mode = m; + slides.forEach((s, i) => { + const current = i === index; + s.hidden = m === 'present' && !current; + if (current) s.setAttribute('aria-current', 'true'); + else s.removeAttribute('aria-current'); + }); + if (counter) counter.textContent = `${index + 1} / ${total}`; + setDisabled(prev, index === 0); + setDisabled(next, index === total - 1); + if (prev) prev.hidden = m !== 'present'; + if (next) next.hidden = m !== 'present'; + if (counter) counter.hidden = m !== 'present'; + if (fullButton) fullButton.hidden = narrow.matches || !document.fullscreenEnabled; + if (modeButton) { + modeButton.hidden = narrow.matches; + modeButton.setAttribute('aria-pressed', String(m === 'list')); + } + if (m === 'present') { + fit(); + warnOverflow(); + } else if (scroll) { + slides[index].scrollIntoView({ block: 'start' }); + } + if (announce && status) status.textContent = `${index + 1} / ${total} 枚目: ${headingOf(slides[index])}`; + const hash = `#${index + 1}`; + if (location.hash !== hash) history.replaceState(null, '', hash); + } + + function go(i) { + const target = clamp(i); + if (target === index) return; + index = target; + render({ announce: true, scroll: true }); + } + + /** 一覧モードで画面の上端に近いスライドを現在のスライドにする */ + function nearestInList() { + const found = slides.findIndex((s) => s.getBoundingClientRect().bottom > 0); + return found < 0 ? index : found; + } + + prev?.addEventListener('click', () => go(index - 1)); + next?.addEventListener('click', () => go(index + 1)); + modeButton?.addEventListener('click', () => { + if (mode() === 'list') index = nearestInList(); + preferList = !preferList; + render({ scroll: true }); + }); + + if (fullButton) { + fullButton.addEventListener('click', () => { + if (document.fullscreenElement) void document.exitFullscreen(); + else { + if (mode() === 'list') { + index = nearestInList(); + preferList = false; + } + void root.requestFullscreen().then(() => render()); + } + }); + document.addEventListener('fullscreenchange', () => { + const label = fullButton.querySelector('.deck-button-label'); + if (label) label.textContent = document.fullscreenElement ? '全画面を終了' : '全画面で表示'; + render(); + }); + } + + document.addEventListener('keydown', (e) => { + if (mode() !== 'present' || e.altKey || e.ctrlKey || e.metaKey) return; + const t = e.target; + if (t instanceof HTMLElement && t.closest('input, textarea, select, [contenteditable]')) return; + const onControl = t instanceof HTMLElement && t.closest('a, button'); + let to = null; + switch (e.key) { + case 'ArrowRight': + case 'ArrowDown': + case 'PageDown': + to = index + 1; + break; + case 'ArrowLeft': + case 'ArrowUp': + case 'PageUp': + to = index - 1; + break; + case ' ': + if (onControl) return; + to = e.shiftKey ? index - 1 : index + 1; + break; + case 'Home': + to = 0; + break; + case 'End': + to = total - 1; + break; + default: + return; + } + e.preventDefault(); + go(to); + }); + + window.addEventListener('hashchange', () => go(fromHash())); + narrow.addEventListener('change', () => render({ scroll: true })); + new ResizeObserver(() => fit()).observe(deck); + + index = fromHash(); + render({ scroll: index > 0 }); +})(); diff --git a/scripts/slides/examples/sample.html b/scripts/slides/examples/sample.html new file mode 100644 index 000000000..fe8f6678b --- /dev/null +++ b/scripts/slides/examples/sample.html @@ -0,0 +1,139 @@ + + + + + +8341-ui で画面づくりを速く、誰にでも使えるものに + + + + +
+

8341-ui で画面づくりを速く、誰にでも使えるものに

+

共通デザインシステムの導入のご提案

+

2026年10月20日 プロダクトデザイン室

+ +
+ +
+

部品とルールを共通にすれば、画面の品質がそろい、作る時間は半分になります

+

今日お願いしたいのは、次の四半期の 2 つのプロダクトで試験導入することです。

+
+ +
+

第 1 章

+

いま起きていること

+
+ +
+

プロダクトごとに部品を作り直し、同じ不具合を繰り返しています

+
    +
  • ボタンだけで 7 種類の実装があり、見た目と挙動が少しずつ違う
  • +
  • アクセシビリティの指摘の 6 割は、ほかのプロダクトで直した問題の再発
  • +
  • 新しい画面のレビューで、毎回同じ観点を口頭で伝えている
  • +
+
+

来年度から公共案件で WCAG 2.2 AA が必須になります

+
+

出典: 2025年度の画面改修 48 件の振り返り

+
+ +
+

共通の部品に置き換えた試行では、実装とレビューの時間が大きく減りました

+
+

−52%

+

画面 1 つあたりの実装時間

+
+
+

−70%

+

レビューでの指摘の数

+
+
+

0 件

+

自動検査でのアクセシビリティ違反

+
+

注: 社内ツール 1 つ(12 画面)で 2026年8〜9月に試行した結果

+
+ +
+

指摘の数は、共通の部品に置き換えた 9 月から下がっています

+
+ + + + + + + 42 件 + 38 件 + 30 件 + 13 件(共通部品) + 6月 + 7月 + 8月 + 9月 + +
月ごとのレビュー指摘の数(社内ツール、12 画面)
+
+
+ +
+

部品だけでなく、判断のルールも一緒に配ります

+
+

トークンと部品

+

色・文字・余白をトークンで決め、18 の部品にまとめています。

+
+
+

画面設計のルール

+

デザイン原則と WCAG 2.2 を、レビューで使えるチェックにしています。

+
+
+

自動の検査

+

コントラストと書き方の違反を、変更のたびに検査します。

+
+
+ +
+

試験導入の費用は、監査を外部に頼むより小さく済みます

+ + + + + + + + + +
項目外部監査試験導入
初期費用320万円80万円
画面 1 つの追加費用12万円3万円
再発の防止報告書のみ自動の検査で継続
+

注: 外部監査は 2026年9月に 2 社から取った見積もりの平均

+
+ +
+

次の四半期に、3 段階で試験導入します

+
    +
  1. 選ぶ

    対象のプロダクトを 2 つ決め、画面の一覧を作る

  2. +
  3. 置き換える

    よく使う部品から順に共通の部品へ置き換える

  4. +
  5. 測る

    実装時間と指摘の数を、導入前と比べる

  6. +
+ +
+ +
+

今日お願いしたいこと

+
    +
  1. 次の四半期に 2 つのプロダクトで試験導入する
  2. +
  3. 各プロダクトから担当者を 1 人ずつ出していただく
  4. +
  5. 結果は 2027年1月の部長会で報告する
  6. +
+

資料と部品の一覧: 8341-ui の Storybook を開く

+
+ + + diff --git a/scripts/slides/lib.ts b/scripts/slides/lib.ts new file mode 100644 index 000000000..e4067d9ed --- /dev/null +++ b/scripts/slides/lib.ts @@ -0,0 +1,687 @@ +/** + * スライド(HTML のデッキ)の共通処理。依存ゼロ(Node.js の型除去で実行)。 + * + * - トークン: packages/tokens の Resolver(scripts/lib/tokens.ts)で semantic トークンを解決し、CSS 変数を作る。 + * `make tokens` を実行していなくても動く。変数名は tokens.css と同じ(--8341-*) + * - HTML: スライドのソースを小さな木に読み込む(ソースはエージェントが書く整った HTML を前提にする) + * - 検査: docs/rulebook/07-slides.md のルール(S1〜S20)のうち、静的に確かめられるもの + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { + PREFIX, + THEMES, + colorToCssRgba, + cssVarName, + resolvedTokens, +} from '../../packages/tokens/scripts/lib/tokens.ts'; +import type { DtcgColor, DtcgDimension } from '../../packages/tokens/scripts/lib/tokens.ts'; + +export const SLIDES_DIR = import.meta.dirname; +export const REPO_ROOT = resolve(SLIDES_DIR, '../..'); + +// --------------------------------------------------------------------------- +// ルールの定数(docs/rulebook/07-slides.md と合わせる) +// --------------------------------------------------------------------------- + +export const LAYOUTS = [ + 'title', + 'section', + 'statement', + 'content', + 'columns', + 'stats', + 'steps', + 'figure', + 'table', + 'summary', +] as const; +export type Layout = (typeof LAYOUTS)[number]; + +/** ソースで使えるクラス(S9)。増やすときは deck.css と 07-slides.md も直す */ +export const ALLOWED_CLASSES = new Set([ + 'slide', + 'subtitle', + 'meta', + 'eyebrow', + 'lead', + 'source', + 'notes', + 'col', + 'card', + 'stat', + 'stat-value', + 'stat-label', + 'steps', + 'num', + 'callout', + 'callout-info', + 'callout-success', + 'callout-warning', + 'callout-danger', + 'tag', + 'tag-key', + 'tag-success', + 'tag-warning', + 'tag-danger', + 'chart-key', + 'chart-base', + 'chart-axis', + 'chart-label', + 'chart-label-subtle', + 'visually-hidden', +]); + +export const LIMITS = { + listItems: 5, + listDepth: 2, + slideChars: 150, + itemChars: 40, + headingChars: 40, + emphasis: 2, + tableRows: 6, + tableCols: 5, +} as const; + +/** 行き先が分からないリンク文言(05 文言) */ +const VAGUE_LINK_TEXT = new Set(['こちら', 'ここ', '詳細', '詳しくはこちら', 'リンク', 'click here', 'here', 'more']); + +const FORBIDDEN_TAGS = new Set(['style', 'script', 'link', 'font', 'center', 'marquee', 'blink', 'iframe']); + +// --------------------------------------------------------------------------- +// トークン → CSS 変数 +// --------------------------------------------------------------------------- + +const LIGHT_SELECTOR = ':root, .light, [data-theme="light"]'; +const DARK_SELECTOR = '.dark, [data-theme="dark"]'; +const DARK_MEDIA_SELECTOR = ':root:not(.light):not([data-theme="light"])'; + +interface ShadowLayer { + color: DtcgColor; + offsetX: DtcgDimension; + offsetY: DtcgDimension; + blur: DtcgDimension; + spread: DtcgDimension; + inset?: boolean; +} + +/** build.ts(Style Dictionary)と同じ規則で値を CSS にする: 寸法は rem(3px 以下と 9999px は px)、字間は em */ +function cssValue(name: string, type: string, v: unknown): string { + if (typeof v === 'string') return v; + switch (type) { + case 'color': + return colorToCssRgba(v as DtcgColor); + case 'dimension': { + const { value, unit } = v as DtcgDimension; + if (unit === 'rem') return `${value}rem`; + if (value <= 3 || value >= 9999) return `${value}px`; + return `${Number((value / 16).toFixed(4))}rem`; + } + case 'fontFamily': + return (Array.isArray(v) ? v : [v]).map((f) => (/[\s\d]/.test(String(f)) ? `"${f}"` : String(f))).join(', '); + case 'duration': { + const d = v as { value: number; unit: string }; + return `${d.value}${d.unit}`; + } + case 'cubicBezier': + return `cubic-bezier(${(v as number[]).join(', ')})`; + case 'shadow': { + const layers = (Array.isArray(v) ? v : [v]) as ShadowLayer[]; + const len = (d: DtcgDimension) => `${d.value}${d.unit}`; + return layers + .map( + (l) => + `${l.inset ? 'inset ' : ''}${len(l.offsetX)} ${len(l.offsetY)} ${len(l.blur)} ${len(l.spread)} ${colorToCssRgba(l.color)}`, + ) + .join(', '); + } + default: + if (typeof v === 'number' && name.endsWith('.letter-spacing')) return `${v}em`; + return String(v); + } +} + +function block(selector: string, lines: string[], colorScheme?: string): string { + const scheme = colorScheme ? ` color-scheme: ${colorScheme};\n` : ''; + return `${selector} {\n${scheme}${lines.map((l) => ` ${l}`).join('\n')}\n}\n`; +} + +/** + * semantic トークンだけの CSS 変数(primitive は出さない=スライドから参照できない)。 + * テーマ非依存 → :root、テーマ依存 → light / dark / OS 追従の 3 ブロック(tokens.css と同じ切り替え方) + */ +export function tokenCss(): { css: string; names: Set } { + const names = new Set(); + const base: string[] = []; + const themed: Record = {}; + for (const theme of THEMES) { + themed[theme] = []; + for (const t of resolvedTokens(theme)) { + if (t.layer !== 'semantic') continue; + const line = `${cssVarName(t.name)}: ${cssValue(t.name, t.type, t.resolved)};`; + names.add(cssVarName(t.name)); + if (t.themed) themed[theme]?.push(line); + else if (theme === THEMES[0]) base.push(line); + } + } + const light = themed.light ?? []; + const dark = themed.dark ?? []; + const media = block(DARK_MEDIA_SELECTOR, dark, 'dark').replace(/^/gm, ' ').trimEnd(); + const css = [ + `/* ${PREFIX}-ui のトークン(semantic のみ。scripts/slides/lib.ts が packages/tokens/src から生成) */`, + block(':root', base), + block(LIGHT_SELECTOR, light, 'light'), + block(DARK_SELECTOR, dark, 'dark'), + `@media (prefers-color-scheme: dark) {\n${media}\n}\n`, + ].join('\n'); + return { css, names }; +} + +// --------------------------------------------------------------------------- +// HTML の木 +// --------------------------------------------------------------------------- + +export interface El { + type: 'el'; + tag: string; + attrs: [string, string | null][]; + children: Node[]; + line: number; + parent?: El; +} +export interface Text { + type: 'text'; + value: string; +} +export interface Comment { + type: 'comment'; + value: string; +} +export type Node = El | Text | Comment; + +const VOID = new Set([ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'source', + 'track', + 'wbr', +]); +const RAW_TEXT = new Set(['script', 'style']); + +export class ParseError extends Error { + line: number; + constructor(message: string, line: number) { + super(message); + this.line = line; + } +} + +function parseAttrs(src: string): [string, string | null][] { + const out: [string, string | null][] = []; + const re = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g; + for (const m of src.matchAll(re)) { + const [, name, dq, sq, uq] = m; + if (!name) continue; + out.push([name.toLowerCase(), dq ?? sq ?? uq ?? null]); + } + return out; +} + +/** 整った HTML を木にする。閉じタグの対応が崩れていたら ParseError */ +export function parseHtml(html: string): El { + const root: El = { type: 'el', tag: '#root', attrs: [], children: [], line: 1 }; + const stack: El[] = [root]; + const re = + /|]*>|<\/([a-zA-Z][\w:-]*)\s*>|<([a-zA-Z][\w:-]*)((?:"[^"]*"|'[^']*'|[^'">])*)>/gi; + let last = 0; + const lineAt = (i: number) => html.slice(0, i).split('\n').length; + const top = () => stack[stack.length - 1] as El; + for (let m = re.exec(html); m; m = re.exec(html)) { + if (m.index > last) top().children.push({ type: 'text', value: html.slice(last, m.index) }); + last = re.lastIndex; + const [whole, comment, closeTag, openTag, rawAttrs = ''] = m; + if (comment !== undefined) { + top().children.push({ type: 'comment', value: comment }); + } else if (closeTag) { + const tag = closeTag.toLowerCase(); + const cur = top(); + if (cur.tag !== tag) + throw new ParseError( + ` に対応する開始タグがありません(開いているのは <${cur.tag}>)`, + lineAt(m.index), + ); + stack.pop(); + } else if (openTag) { + const tag = openTag.toLowerCase(); + const selfClosing = /\/\s*$/.test(rawAttrs); + const el: El = { + type: 'el', + tag, + attrs: parseAttrs(rawAttrs.replace(/\/\s*$/, '')), + children: [], + line: lineAt(m.index), + parent: top(), + }; + top().children.push(el); + if (RAW_TEXT.has(tag)) { + const end = html.toLowerCase().indexOf(` が閉じられていません`, el.line); + el.children.push({ type: 'text', value: html.slice(last, end) }); + const close = html.indexOf('>', end); + re.lastIndex = last = close + 1; + } else if (!VOID.has(tag) && !selfClosing) { + stack.push(el); + } + } else if (whole) { + // doctype は捨てる(出力側で付け直す) + } + } + if (last < html.length) top().children.push({ type: 'text', value: html.slice(last) }); + if (stack.length > 1) { + const open = top(); + throw new ParseError(`<${open.tag}> が閉じられていません`, open.line); + } + return root; +} + +export const attr = (el: El, name: string): string | null | undefined => el.attrs.find(([k]) => k === name)?.[1]; +export const hasAttr = (el: El, name: string) => el.attrs.some(([k]) => k === name); +export function setAttr(el: El, name: string, value: string | null) { + const found = el.attrs.find(([k]) => k === name); + if (found) found[1] = value; + else el.attrs.push([name, value]); +} +export const classes = (el: El) => (attr(el, 'class') ?? '').split(/\s+/).filter(Boolean); +export const hasClass = (el: El, c: string) => classes(el).includes(c); +export const elements = (n: El) => n.children.filter((c): c is El => c.type === 'el'); + +export function walk(n: El, fn: (el: El) => void) { + for (const c of elements(n)) { + fn(c); + walk(c, fn); + } +} +export function find(n: El, pred: (el: El) => boolean): El[] { + const out: El[] = []; + walk(n, (el) => { + if (pred(el)) out.push(el); + }); + return out; +} +export const findTag = (n: El, tag: string) => find(n, (el) => el.tag === tag); + +const ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }; +export const decode = (s: string) => + s.replace(/&(#x[\da-f]+|#\d+|\w+);/gi, (all, e: string) => { + if (e[0] === '#') + return String.fromCodePoint(e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1))); + return ENTITIES[e.toLowerCase()] ?? all; + }); +export const escapeHtml = (s: string) => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +/** 要素の文字(発表者メモ・出典・SVG の中は除くかを選べる) */ +export function textOf(n: Node, skip: (el: El) => boolean = () => false): string { + if (n.type === 'text') return decode(n.value); + if (n.type === 'comment') return ''; + if (skip(n)) return ''; + return n.children.map((c) => textOf(c, skip)).join(''); +} +export const compact = (s: string) => s.replace(/\s+/g, ' ').trim(); +/** 空白を除いた字数(和文の分量の目安) */ +export const charCount = (s: string) => s.replace(/\s+/g, '').length; + +export function serialize(n: Node): string { + if (n.type === 'text') return n.value; + if (n.type === 'comment') return ``; + const inner = n.children.map(serialize).join(''); + if (n.tag === '#root') return inner; + const attrs = n.attrs.map(([k, v]) => (v === null ? ` ${k}` : ` ${k}="${v.replace(/"/g, '"')}"`)).join(''); + if (VOID.has(n.tag)) return `<${n.tag}${attrs}>`; + return `<${n.tag}${attrs}>${inner}`; +} + +// --------------------------------------------------------------------------- +// デッキの読み込み +// --------------------------------------------------------------------------- + +export interface Deck { + root: El; + html?: El; + body?: El; + title: string; + lang: string | null | undefined; + theme: string | null | undefined; + description: string | null | undefined; + slides: El[]; +} + +export function readDeck(file: string): Deck { + const root = parseHtml(readFileSync(file, 'utf8')); + const html = findTag(root, 'html')[0]; + const body = findTag(root, 'body')[0]; + const titleEl = findTag(root, 'title')[0]; + const desc = find(root, (el) => el.tag === 'meta' && attr(el, 'name') === 'description')[0]; + return { + root, + html, + body, + title: titleEl ? compact(textOf(titleEl)) : '', + lang: html ? attr(html, 'lang') : undefined, + theme: html ? attr(html, 'data-theme') : undefined, + description: desc ? attr(desc, 'content') : undefined, + slides: body ? elements(body).filter((el) => el.tag === 'section' && hasClass(el, 'slide')) : [], + }; +} + +// --------------------------------------------------------------------------- +// 検査 +// --------------------------------------------------------------------------- + +export interface Issue { + id: string; + level: 'error' | 'warning'; + /** 1 始まりのスライド番号(デッキ全体なら undefined) */ + slide?: number; + line?: number; + message: string; +} + +const isNotes = (el: El) => el.tag === 'aside' && hasClass(el, 'notes'); +/** 分量(S4)に数えない要素: 発表者メモ・出典・図と表(図表は S12〜S14 で別に見る) */ +const notCounted = (el: El) => isNotes(el) || hasClass(el, 'source') || el.tag === 'svg' || el.tag === 'table'; + +function listDepth(el: El): number { + let d = 0; + for (let p: El | undefined = el; p; p = p.parent) if (p.tag === 'ul' || p.tag === 'ol') d++; + return d; +} + +export function checkDeck(deck: Deck): Issue[] { + const issues: Issue[] = []; + const add = (i: Issue) => issues.push(i); + + if (!deck.html) add({ id: 'doc-html', level: 'error', message: ' がありません' }); + if (!deck.lang) + add({ id: 'doc-lang', level: 'error', message: ' のように言語を書いてください(S19)' }); + if (!deck.title) add({ id: 'doc-title', level: 'error', message: ' にデッキの題を書いてください(S19)' }); + if (deck.theme && !['light', 'dark'].includes(deck.theme)) + add({ id: 'doc-theme', level: 'error', message: `data-theme は light か dark です("${deck.theme}")` }); + if (!deck.body) { + add({ id: 'structure', level: 'error', message: '<body> がありません' }); + return issues; + } + + // body の直下は section.slide だけ + for (const c of deck.body.children) { + if (c.type === 'text' && c.value.trim()) + add({ + id: 'structure', + level: 'error', + message: `<body> の直下に文字があります: "${compact(c.value).slice(0, 20)}"`, + }); + if (c.type === 'el' && !(c.tag === 'section' && hasClass(c, 'slide'))) + add({ + id: 'structure', + level: 'error', + line: c.line, + message: `<body> の直下には <section class="slide"> だけを置きます(<${c.tag}>)`, + }); + } + if (deck.slides.length === 0) add({ id: 'structure', level: 'error', message: 'スライドがありません' }); + + // 文書全体: 禁止タグ・style・クラス + walk(deck.root, (el) => { + if (FORBIDDEN_TAGS.has(el.tag) && !(el.tag === 'link' && attr(el, 'rel') !== 'stylesheet')) + add({ + id: el.tag === 'style' || el.tag === 'link' ? 'style-tag' : 'tag-forbidden', + level: 'error', + line: el.line, + message: `<${el.tag}> は使えません。見た目と動きはビルドが付けます(S9)`, + }); + if (hasAttr(el, 'style')) + add({ id: 'style-attr', level: 'error', line: el.line, message: `<${el.tag}> の style 属性は使えません(S9)` }); + if (el.attrs.some(([k]) => k.startsWith('on'))) + add({ id: 'tag-forbidden', level: 'error', line: el.line, message: `<${el.tag}> にイベント属性は書けません` }); + for (const c of classes(el)) + if (!ALLOWED_CLASSES.has(c)) + add({ + id: 'class-unknown', + level: 'error', + line: el.line, + message: `クラス "${c}" は使えません。使えるクラスは docs/rulebook/07-slides.md の「使えるクラス」(S9)`, + }); + }); + + let h1Count = 0; + deck.slides.forEach((slide, i) => { + const n = i + 1; + const at = (id: string, level: Issue['level'], message: string, line = slide.line) => + add({ id, level, slide: n, line, message }); + const layout = attr(slide, 'data-layout'); + if (!layout) at('layout-missing', 'error', 'data-layout を指定してください(title / content など)'); + else if (!(LAYOUTS as readonly string[]).includes(layout)) + at('layout-unknown', 'error', `data-layout="${layout}" はありません。使えるのは ${LAYOUTS.join(' / ')}`); + + // 見出し(S2) + const h1 = findTag(slide, 'h1'); + const h2 = findTag(slide, 'h2'); + const h3 = findTag(slide, 'h3'); + h1Count += h1.length; + if (layout === 'title') { + if (h1.length !== 1) at('heading-h1', 'error', '表紙には h1 を 1 つ置きます'); + if (h2.length) at('heading-h2', 'error', '表紙に h2 は置きません(デッキの題は h1)'); + if (h3.length) at('heading-level', 'error', '表紙に h3 は置きません'); + if (i !== 0) at('heading-h1', 'warning', '表紙は先頭に置きます'); + } else { + if (h1.length) at('heading-h1', 'error', 'h1 は表紙だけに置きます。このスライドの見出しは h2'); + if (h2.length !== 1) at('heading-h2', 'error', `スライドには h2 を 1 つ置きます(${h2.length} 個)`); + } + for (const h of find(slide, (el) => /^h[4-6]$/.test(el.tag))) + at('heading-level', 'error', `<${h.tag}> は使えません。スライドの中の小見出しは h3 までにします`, h.line); + for (const h of [...h1, ...h2]) { + const len = charCount(textOf(h)); + if (len === 0) at('heading-empty', 'error', '見出しが空です', h.line); + if (len > LIMITS.headingChars) + at('text-heading', 'warning', `見出しが ${len} 字です。${LIMITS.headingChars} 字(2 行)以内に(S4)`, h.line); + } + + // 箇条書き(S3・S4) + for (const list of find(slide, (el) => el.tag === 'ul' || el.tag === 'ol')) { + const items = elements(list).filter((c) => c.tag === 'li'); + if (items.length > LIMITS.listItems) + at( + 'list-items', + 'error', + `箇条書きが ${items.length} 項目あります。${LIMITS.listItems} 項目以内に分けてください(S3)`, + list.line, + ); + if (listDepth(list) > LIMITS.listDepth) + at('list-depth', 'error', `箇条書きの入れ子は ${LIMITS.listDepth} 段までです(S3)`, list.line); + for (const li of items) { + const len = charCount( + textOf(li, (el) => el.tag === 'ul' || el.tag === 'ol' || el.tag === 'p' || /^h\d$/.test(el.tag)), + ); + if (len > LIMITS.itemChars) + at( + 'text-item', + 'warning', + `箇条書きの 1 項目が ${len} 字です。${LIMITS.itemChars} 字(2 行)以内に(S4)`, + li.line, + ); + } + } + + // 分量(S4)・強調(S5) + const chars = charCount(textOf(slide, notCounted)); + if (chars > LIMITS.slideChars) + at( + 'text-slide', + 'warning', + `本文が ${chars} 字あります。${LIMITS.slideChars} 字以内にし、残りは発表者メモへ(S4・S8)`, + ); + const strong = findTag(slide, 'strong').length + findTag(slide, 'b').length; + if (strong > LIMITS.emphasis) + at('emphasis-many', 'warning', `強調が ${strong} か所あります。${LIMITS.emphasis} か所までに(S5)`); + if (findTag(slide, 'b').length) at('emphasis-tag', 'warning', '<b> ではなく <strong> を使います'); + + // リンク(S6) + for (const a of findTag(slide, 'a')) { + if (!attr(a, 'href')) at('link-href', 'error', '<a> に href がありません', a.line); + const text = compact(textOf(a)); + if (!text) at('link-text', 'error', 'リンクの文字が空です', a.line); + else if (VAGUE_LINK_TEXT.has(text.toLowerCase())) + at( + 'link-text', + 'error', + `リンクの文字「${text}」では行き先が分かりません。行き先を文で書いてください(S6)`, + a.line, + ); + } + + // 画像・図(S11〜S13) + for (const img of findTag(slide, 'img')) + if (!hasAttr(img, 'alt')) at('img-alt', 'error', '<img> に alt がありません。飾りなら alt=""(S13)', img.line); + for (const svg of findTag(slide, 'svg')) { + let nested = false; + for (let p = svg.parent; p; p = p.parent) if (p.tag === 'svg') nested = true; + if (nested) continue; + const hidden = attr(svg, 'aria-hidden') === 'true'; + const named = + hasAttr(svg, 'aria-label') || hasAttr(svg, 'aria-labelledby') || elements(svg).some((c) => c.tag === 'title'); + if (!hidden && (attr(svg, 'role') !== 'img' || !named)) + at( + 'svg-name', + 'error', + '<svg> には role="img" と aria-label(または <title>)を付けます。飾りなら aria-hidden="true"(S13)', + svg.line, + ); + walk(svg, (el) => { + for (const k of ['fill', 'stroke', 'color', 'stop-color']) { + const v = attr(el, k); + if (v && !['none', 'currentcolor', 'transparent'].includes(v.toLowerCase())) + at('chart-color', 'error', `SVG の ${k}="${v}" は使えません。chart-* クラスで色を付けます(S12)`, el.line); + } + }); + } + if (layout === 'figure') { + const fig = findTag(slide, 'figure')[0]; + if (!fig) at('figure-missing', 'error', 'figure レイアウトには <figure> を置きます'); + else if (!findTag(fig, 'figcaption').length) + at('figure-caption', 'warning', '<figcaption> で図の読み方か出典を書きます', fig.line); + } + + // 表(S14) + for (const table of findTag(slide, 'table')) { + if (!findTag(table, 'th').length) at('table-th', 'error', '表に見出しセル(th)がありません(S14)', table.line); + const rows = find(table, (el) => el.tag === 'tr' && el.parent?.tag !== 'thead'); + const cols = Math.max(0, ...findTag(table, 'tr').map((tr) => elements(tr).length)); + if (rows.length > LIMITS.tableRows || cols > LIMITS.tableCols) + at( + 'table-size', + 'warning', + `表が ${rows.length} 行 × ${cols} 列です。${LIMITS.tableRows} 行 × ${LIMITS.tableCols} 列以内に(S14)`, + table.line, + ); + } + + // レイアウトごとの個数 + const count = (pred: (el: El) => boolean) => elements(slide).filter(pred).length; + const range = (what: string, got: number, min: number, max: number) => { + if (got < min || got > max) + at( + 'layout-count', + got === 0 ? 'error' : 'warning', + `${layout} レイアウトの${what}は ${min}〜${max} 個です(${got} 個)`, + ); + }; + if (layout === 'columns') + range( + ' .col ', + count((el) => hasClass(el, 'col')), + 2, + 3, + ); + if (layout === 'stats') + range( + ' .stat ', + count((el) => hasClass(el, 'stat')), + 2, + 4, + ); + if (layout === 'steps') { + const steps = elements(slide).find((el) => el.tag === 'ol' && hasClass(el, 'steps')); + range('手順(ol.steps の li)', steps ? elements(steps).length : 0, 3, 5); + } + if (layout === 'table' && !findTag(slide, 'table').length) + at('layout-count', 'error', 'table レイアウトには <table> を置きます'); + + // 発表者メモ・出典はスライドの直下に + for (const notes of find(slide, isNotes)) + if (notes.parent !== slide) at('notes-place', 'error', 'aside.notes はスライドの直下に置きます', notes.line); + }); + if (deck.slides.length && h1Count === 0) + add({ + id: 'heading-h1', + level: 'error', + message: '表紙(data-layout="title")の h1 でデッキの題を示してください(S2)', + }); + + return issues; +} + +/** deck.css 自体の監査(S10・S15・S20 の担保。トークン以外の値を書いていないか) */ +export function checkCss(css: string, tokenNames: Set<string>): Issue[] { + const issues: Issue[] = []; + const lines = css.replace(/\/\*[\s\S]*?\*\//g, (c) => c.replace(/[^\n]/g, ' ')).split('\n'); + const deckVars = new Set([...css.matchAll(/(--deck-[\w-]+)\s*:/g)].map((m) => m[1])); + lines.forEach((line, i) => { + const at = (id: string, message: string) => issues.push({ id, level: 'error', line: i + 1, message }); + if (/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch|color-mix)\(/i.test(line)) + at('css-raw-color', `色を直書きしています: ${line.trim()}`); + if (/:\s*(?:[^;]*\s)?(?:black|white|gray|grey|red|blue|green|yellow)\b/i.test(line) && !/font|content/.test(line)) + at('css-raw-color', `色名を直書きしています: ${line.trim()}`); + // メディアクエリは var() を使えないため px を許す + if (!/^\s*@media/.test(line)) + for (const m of line.matchAll(/(-?\d*\.?\d+)px\b/g)) + if (!['0', '1', '-1'].includes(m[1] ?? '')) at('css-raw-length', `px を直書きしています: ${line.trim()}`); + if (/font-weight:\s*\d/.test(line)) at('css-font-weight', `太さはトークンで指定します: ${line.trim()}`); + if (/text-align:\s*(?:center|justify)/.test(line)) + at('css-align', `中央揃え・両端揃えは使いません(S15): ${line.trim()}`); + if (/\b(?:transition|animation)\s*:/.test(line)) + at('css-motion', `切り替えの動きは使いません(S15): ${line.trim()}`); + if (/linear-gradient|radial-gradient/.test(line)) + at('css-decoration', `グラデーションは使いません(S15): ${line.trim()}`); + if (/--color-|--spacing-|--text-/.test(line.replace(/--\d+-|--deck-/g, ''))) + at('css-theme-var', `Tailwind のテーマ変数は参照しません: ${line.trim()}`); + for (const m of line.matchAll(/var\((--[\w-]+)/g)) { + const name = m[1] ?? ''; + if (name.startsWith(`--${PREFIX}-color-primitive`)) + at('css-primitive', `primitive トークンは参照しません: ${name}`); + else if (name.startsWith(`--${PREFIX}-`) && !tokenNames.has(name)) + at('css-unknown-var', `存在しないトークンです: ${name}`); + else if (!name.startsWith(`--${PREFIX}-`) && !deckVars.has(name)) + at('css-unknown-var', `未定義の変数です: ${name}`); + } + }); + return issues; +} + +export function formatIssues(file: string, issues: Issue[]): string { + return issues + .map((i) => { + const where = [i.slide ? `スライド ${i.slide}` : '', i.line ? `${file}:${i.line}` : file] + .filter(Boolean) + .join(' / '); + return `${i.level === 'error' ? '✖' : '⚠'} [${i.id}] ${i.message}(${where})`; + }) + .join('\n'); +} From e2b35f31b68b01bd38af177d379eef942f47b94f Mon Sep 17 00:00:00 2001 From: tkc310 <takashisato.develop@gmail.com> Date: Tue, 20 Oct 2026 13:18:57 +0000 Subject: [PATCH 2/2] =?UTF-8?q?style(slides):=20=E8=A6=8B=E6=9C=AC?= =?UTF-8?q?=E3=81=AE=20HTML=20=E3=82=92=E3=83=AA=E3=83=9D=E3=82=B8?= =?UTF-8?q?=E3=83=88=E3=83=AA=E3=81=AE=E6=95=B4=E5=BD=A2=E8=A6=8F=E5=89=87?= =?UTF-8?q?=EF=BC=88oxfmt=EF=BC=89=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make check の fmt で見本(scripts/slides/examples/sample.html)が未整形として失敗していたため整形した。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Yb44feYKDNj2ukQeT2j8Ks --- scripts/slides/examples/sample.html | 273 +++++++++++++++------------- 1 file changed, 151 insertions(+), 122 deletions(-) diff --git a/scripts/slides/examples/sample.html b/scripts/slides/examples/sample.html index fe8f6678b..6c2205786 100644 --- a/scripts/slides/examples/sample.html +++ b/scripts/slides/examples/sample.html @@ -5,135 +5,164 @@ ルール: docs/rulebook/07-slides.md --> <html lang="ja" data-theme="light"> -<head> -<meta charset="utf-8"> -<title>8341-ui で画面づくりを速く、誰にでも使えるものに - - - + + + 8341-ui で画面づくりを速く、誰にでも使えるものに + + + +
+

8341-ui で画面づくりを速く、誰にでも使えるものに

+

共通デザインシステムの導入のご提案

+

2026年10月20日 プロダクトデザイン室

+ +
-
-

8341-ui で画面づくりを速く、誰にでも使えるものに

-

共通デザインシステムの導入のご提案

-

2026年10月20日 プロダクトデザイン室

- -
+
+

部品とルールを共通にすれば、画面の品質がそろい、作る時間は半分になります

+

今日お願いしたいのは、次の四半期の 2 つのプロダクトで試験導入することです。

+
-
-

部品とルールを共通にすれば、画面の品質がそろい、作る時間は半分になります

-

今日お願いしたいのは、次の四半期の 2 つのプロダクトで試験導入することです。

-
+
+

第 1 章

+

いま起きていること

+
-
-

第 1 章

-

いま起きていること

-
+
+

プロダクトごとに部品を作り直し、同じ不具合を繰り返しています

+
    +
  • ボタンだけで 7 種類の実装があり、見た目と挙動が少しずつ違う
  • +
  • アクセシビリティの指摘の 6 割は、ほかのプロダクトで直した問題の再発
  • +
  • 新しい画面のレビューで、毎回同じ観点を口頭で伝えている
  • +
+
+

来年度から公共案件で WCAG 2.2 AA が必須になります

+
+

出典: 2025年度の画面改修 48 件の振り返り

+
-
-

プロダクトごとに部品を作り直し、同じ不具合を繰り返しています

-
    -
  • ボタンだけで 7 種類の実装があり、見た目と挙動が少しずつ違う
  • -
  • アクセシビリティの指摘の 6 割は、ほかのプロダクトで直した問題の再発
  • -
  • 新しい画面のレビューで、毎回同じ観点を口頭で伝えている
  • -
-
-

来年度から公共案件で WCAG 2.2 AA が必須になります

-
-

出典: 2025年度の画面改修 48 件の振り返り

-
+
+

共通の部品に置き換えた試行では、実装とレビューの時間が大きく減りました

+
+

−52%

+

画面 1 つあたりの実装時間

+
+
+

−70%

+

レビューでの指摘の数

+
+
+

0 件

+

自動検査でのアクセシビリティ違反

+
+

注: 社内ツール 1 つ(12 画面)で 2026年8〜9月に試行した結果

+
-
-

共通の部品に置き換えた試行では、実装とレビューの時間が大きく減りました

-
-

−52%

-

画面 1 つあたりの実装時間

-
-
-

−70%

-

レビューでの指摘の数

-
-
-

0 件

-

自動検査でのアクセシビリティ違反

-
-

注: 社内ツール 1 つ(12 画面)で 2026年8〜9月に試行した結果

-
+
+

指摘の数は、共通の部品に置き換えた 9 月から下がっています

+
+ + + + + + + 42 件 + 38 件 + 30 件 + 13 件(共通部品) + 6月 + 7月 + 8月 + 9月 + +
月ごとのレビュー指摘の数(社内ツール、12 画面)
+
+
-
-

指摘の数は、共通の部品に置き換えた 9 月から下がっています

-
- - - - - - - 42 件 - 38 件 - 30 件 - 13 件(共通部品) - 6月 - 7月 - 8月 - 9月 - -
月ごとのレビュー指摘の数(社内ツール、12 画面)
-
-
+
+

部品だけでなく、判断のルールも一緒に配ります

+
+

トークンと部品

+

色・文字・余白をトークンで決め、18 の部品にまとめています。

+
+
+

画面設計のルール

+

デザイン原則と WCAG 2.2 を、レビューで使えるチェックにしています。

+
+
+

自動の検査

+

コントラストと書き方の違反を、変更のたびに検査します。

+
+
-
-

部品だけでなく、判断のルールも一緒に配ります

-
-

トークンと部品

-

色・文字・余白をトークンで決め、18 の部品にまとめています。

-
-
-

画面設計のルール

-

デザイン原則と WCAG 2.2 を、レビューで使えるチェックにしています。

-
-
-

自動の検査

-

コントラストと書き方の違反を、変更のたびに検査します。

-
-
+
+

試験導入の費用は、監査を外部に頼むより小さく済みます

+ + + + + + + + + + + + + + + + + + + + + + + + + +
項目外部監査試験導入
初期費用320万円80万円
画面 1 つの追加費用12万円3万円
再発の防止報告書のみ自動の検査で継続
+

注: 外部監査は 2026年9月に 2 社から取った見積もりの平均

+
-
-

試験導入の費用は、監査を外部に頼むより小さく済みます

- - - - - - - - - -
項目外部監査試験導入
初期費用320万円80万円
画面 1 つの追加費用12万円3万円
再発の防止報告書のみ自動の検査で継続
-

注: 外部監査は 2026年9月に 2 社から取った見積もりの平均

-
+
+

次の四半期に、3 段階で試験導入します

+
    +
  1. +

    選ぶ

    +

    対象のプロダクトを 2 つ決め、画面の一覧を作る

    +
  2. +
  3. +

    置き換える

    +

    よく使う部品から順に共通の部品へ置き換える

    +
  4. +
  5. +

    測る

    +

    実装時間と指摘の数を、導入前と比べる

    +
  6. +
+ +
-
-

次の四半期に、3 段階で試験導入します

-
    -
  1. 選ぶ

    対象のプロダクトを 2 つ決め、画面の一覧を作る

  2. -
  3. 置き換える

    よく使う部品から順に共通の部品へ置き換える

  4. -
  5. 測る

    実装時間と指摘の数を、導入前と比べる

  6. -
- -
- -
-

今日お願いしたいこと

-
    -
  1. 次の四半期に 2 つのプロダクトで試験導入する
  2. -
  3. 各プロダクトから担当者を 1 人ずつ出していただく
  4. -
  5. 結果は 2027年1月の部長会で報告する
  6. -
-

資料と部品の一覧: 8341-ui の Storybook を開く

-
- - +
+

今日お願いしたいこと

+
    +
  1. 次の四半期に 2 つのプロダクトで試験導入する
  2. +
  3. 各プロダクトから担当者を 1 人ずつ出していただく
  4. +
  5. 結果は 2027年1月の部長会で報告する
  6. +
+

+ 資料と部品の一覧: 8341-ui の Storybook を開く +

+
+