@novi-ui/flatlay
v0.5.0
Published
Novi UI theme 3 — no z-axis. Opening takes space: inflow expansion, full-screen takeover, rules instead of shadows.
Readme
@novi-ui/flatlay
Novi UI のテーマ3。z 軸を持たない。
浮く層が無いので、開くものは場所を取ります。展開はその場を押し下げ、 Modal は画面を占め、階層は罫線と地色と面積だけで作ります。
pnpm add @novi-ui/flatlay @novi-ui/core react-aria-componentsTailwind CSS v4 が必要です。 テーマの CSS はトークン定義だけで、コンポーネントのクラスは利用側の Tailwind が生成します。
globals.cssに@source "../node_modules/@novi-ui/flatlay/dist";を足してください(パスは CSS ファイルからの相対)。
import '@novi-ui/core/base.css'
import '@novi-ui/flatlay/flatlay.css'
import { Button } from '@novi-ui/flatlay'
export function Example() {
return <Button color="primary">保存</Button>
}Provider は要りません。テーマは CSS 変数だけで効きます。
@novi-ui/raster / @novi-ui/tactile と公開 API は完全に同一です。
import 元を差し替えるだけで構造ごと切り替わります。
他の2つとどう違うか
| | Raster | Tactile | Flatlay | |---|---|---|---| | 前提の入力 | ポインタ | 指 | ポインタ + キーボード | | 階層の作り方 | 線で切る | 面を持ち上げる | 罫線・地色・面積だけ | | 影 | 使う | 面にも使う | 全段が透明(存在しない) | | 展開(Select / Menu / Popover) | 隣に浮く | 下から出るシート | その場を押し下げる | | Modal | 中央のダイアログ | 下から出るシート | 全画面テイクオーバー | | Toast | 右下に浮く | 上端に浮く | フローに挿入される帯 | | 押下の表現 | 色 | 0.97 倍に沈む | 面と文字が入れ替わる | | 高さ | 32 / 40 / 48px | 40 / 48 / 56px | 28 / 32 / 40px | | 中立色 | 染まらない紙 | 染まる生地 | 地は染まらず罫線だけが染まる |
指で触る画面なら Tactile を選んでください。 Flatlay はタッチの下限(44px)を 意図的に満たしていません。帳票の密度と両立しないためです。
デザイン言語(数値定義)
「z 軸を持たない」を主観で運用すると必ず崩れるので、すべて規則にしています。
違反は CI(scripts/check-design-rules.mjs・9 ルール)が検出します。
| 項目 | 規則 |
|---|---|
| z-index | 禁止。例外なし。 重なりの順序は DOM 順だけで表す |
| position | fixed / absolute / sticky は例外2ファイルのみ(Modal / Tooltip) |
| 影 | 全段 0 0 #0000。 none ではないのは ring が box-shadow に合成されるため |
| transform | 禁止(Spinner の回転のみ例外)。押下で沈む・縮むは z 軸の語彙 |
| 角丸 | sm 2px / md 2px / lg 4px。書類の直角 |
| コンポーネント高さ | sm 28px / md 32px / lg 40px |
| タイポスケール | 11 / 13 / 16 / 18 / 22 / 26 / 32px(比率 ≒1.18) |
| モーション | 1本(100ms)。 展開・格納はアニメーションしない |
| 罫線の彩度 | chroma 0.02〜0.03。border-strong は地に対し 3:1 以上 |
| 中立色 | 地・文字は chroma 0(無彩)。染まるのは罫線2本だけ |
色を選ぶ
Flatlay は Stationery という8色のカラーセットを持ちます。全色が机の上の
事務道具の実名です。data-novi-color 属性ひとつで切り替わります。
<html data-novi-color="blueprint">| 色 | hue | 出自 | 差し色(バイカラー) |
|---|---|---|---|
| fieldbook (既定) | 172 | 測量野帳の表紙の緑 | eraser |
| blueprint | 215 | 青焼き図面の青 | manila |
| carbon | 288 | カーボン複写紙の紫 | legalpad |
| ribbon | 330 | タイプライターのインクリボン | fieldbook |
| eraser | 352 | ピンクの消しゴム | pencil |
| manila | 58 | マニラ封筒の黄土 | blueprint |
| legalpad | 98 | 黄色いリーガルパッド | carbon |
| pencil | 240 | 鉛筆の芯(無彩枠) | eraser |
- トーンはテーマが持ちます。 どの色を選んでも L47% / C0.070(dark は L75% / C0.060)
- 染まるのは罫線だけです。 地も文字も無彩のまま。唯一の階層表現である罫線に 色を持たせないと、色を選んだ実感がどこにも出ません
- 赤がありません。 校正の朱書きはエラーの道具なので、赤は
dangerに予約しています。 primary が danger と紛れる事故を、規則ではなく世界観で防いでいます - 差し色が組で付いてきます。
--novi-color-secondaryに表の相方が入ります - 知らない名前を書いても壊れません。 既定の
fieldbookで描画されます
Raster(ink / brick など)・Tactile(indigo / madder など)の色名とは
1つも重なりません。 テーマを切り替えると既定色に戻ります。
基準パターン(コンポーネント実装の型)
Button がこの型を定義しています。以降のコンポーネントはこれを踏襲します。
ファイル構成
src/<name>/
<name>.tsx コンポーネント本体('use client' はエントリ側で宣言)
<name>.styles.ts tv() 定義。named export する
<name>.test.tsx 5点セット + Flatlay 固有の差分
index.ts 2行の再輸出スタイル定義の型
- slot は型注釈ではなく
satisfies SlotMap<...>で書く。型注釈だと任意 slot がundefined扱いになり、s.startContent()が呼べなくなる(ADR-R5) - 色は
--c/--c-fg/--c-text/--c-lineのローカル変数に落としてから variant が参照する。variant 5 × color 6 = 30 通りを書かないため variantsは{ color, size, radius, variant }の順で宣言する。variantを先に書くと size のクラスに負ける- base に色を書かない。 tailwind-merge が同族クラスを後勝ちでマージするため、 全 variant が上書きする性質(border 色など)を base に置くと死ぬコードになる
テストの5点セット
- デフォルト props でレンダリングできる
- slot 契約を満たす(
testSlotContract) - a11y 違反がない
- 全 variant / size / color が互いに異なるクラスを適用する
classNamesが該当 slot に反映される
これに Flatlay 固有の3点を足します。
- 押下で面と文字が入れ替わる。離すと元に戻る(Button は状態を持たない)
- z 軸の語彙(
scale/translate/rotate/shadow-/z-)を持たない - 色はすべてトークン経由(リテラルを書かない)
合成後のクラスに対する検査は src/styles/cross-cutting.test.ts が担当します。
公開エントリから *Styles を自動収集するので、コンポーネントを足すだけで対象が増えます。
知っておくべき仕様
size は幅ではなく本文の行長です(Modal)
Modal は全画面テイクオーバーなので面積が固定されます。幅(Raster)にも
高さ(Tactile)にも段階を作れないので、size は本文の最大行長として
解釈します(sm 32rem / md 44rem / lg 56rem / full 制限なし)。
語彙は core が共通で固定し、解釈はテーマの自由、という設計です。同じコードが テーマを跨いでそのまま動くのはこのためで、不具合ではありません。
Modal の radius は効きません
全画面を占める面に角はありません。書いても描画に出ないので、解釈を 持たせていません(渡しても黙って無視されます)。
閉じるは「← 戻る」です(Modal)
ヘッダ行の左端にあります。右上の ✕(Raster)・フッターのフルワイド(Tactile)に 続く第3の位置で、書類の「戻る」が常に左上にあることに合わせています。
地が暗転しないので、文書が切り替わったことを示すのはこのボタンと下辺の罫線だけです。
開くと後続が下がります(Select)
選択肢はトリガーの隣にも画面下端にも出ません。トリガーの直後、同じフローの中に 展開されて、下にあるものを押し下げます。浮く層が無いので「どこかが隠れている」が 起きません。閉じると元の位置に戻ります。
選択済みの印は行頭の ▸ です。チェックマーク(他の2テーマ)ではないのは、押下の
反転と役割を分けるためで、状態は記号、押した瞬間は面が示します。
開閉の向きも ▾ / ▴ の差し替えで、矢印は回りません。
Menu も後続を押し下げます
Select と同じインフロー展開です。一覧はトリガーの直後に生え、後続が下がります。
placement / offset は受け取りますが効きません。浮かないものに寄せる先は
無いためで、他テーマ向けに書いたコードがそのまま動くよう受け取ること自体はやめていません。
ショートカット(shortcut)は主役です。両テーマは muted の小さな注記として
右端に添えますが、帳票の世界では「キーで引ける」ことが操作の見出しになります。
本文と同じ濃さ・同じ大きさで、等幅の列として縦に揃います。
行の間には罫線が入り、MenuSeparator はそれより濃い線でまとまりの切れ目を示します。
MenuSection の見出しは地を一段落とした表のヘッダ行です。
Popover は注記の面です(浮きません)
トリガーの直後、フローの中に面が生えます。Select / Menu と違うのは地の色で、
展開部が紙と同じ地(bg)なのに対して、Popover は一段落とした地(subtle)です。
一覧の続きではなく、本文に貼った注記であることを地の差で読ませます。
arrow slot は描きません。浮いていないので指す先が無く、矢印は嘘になります。
placement / offset は Menu と同じく受け取るだけで効きません。
通知は帯としてフローに挿さります(Toast)
NoviToastRegion は右下(Raster)にも上端中央(Tactile)にも固定しません。置いた場所に
出ます。アプリの先頭、ヘッダの直下あたりに1つ置いてください。通知が出ると帯が生えて
後続が押し下がり、閉じると元に戻ります。
<Header />
<NoviToastRegion queue={toast} />
<main>…</main>sticky も使いません。滞留もまた重なりだからです。スクロールすれば通知は流れて
見えなくなります(帳票の朱書きはページの先頭にあり、読み進めるあいだ追いかけてはきません)。
この割り切りの代償は「長いページでは気づきにくい」ことです。見落とすと困る確認を
Toast に置かないでください。 取り返しのつかない操作の確認は Modal(テイクオーバー)です。
色は左端の太い罫だけが受け持ちます。地を塗り分けないのは、通知が「別の種類の紙」ではなく 「同じ紙に入った書き込み」だからです。
浮くのは Tooltip だけです
Flatlay で唯一 z 軸を持つのが Tooltip です。フローに入れた瞬間にレイアウトが動いて
ポインタがトリガーから外れ、出た瞬間に閉じてしまうため、ここだけは浮かせています。
浮くことを認めた代わりに、紙の上の面には見せません。地と文字を反転したインクの札で、 書類の一部ではなく「一時的に当てた付箋」として読ませます。影は使いません。 浮いていることは反転色と重なりの事実が示します。
そのため Tooltip では placement / offset が効きます(Popover / Menu / Select と
逆なので注意してください)。浮くものはこの2つ(Modal と Tooltip)で凍結しています。
Accordion はこのテーマの主役です
「開く = 場所を取る」という Flatlay の原理を、もともと素で満たしていた唯一の コンポーネントです。他の overlay を直して辿り着いた形に、Accordion は最初から居ます。
開閉の印は ▸ と ▾ の差し替えです。回転させません。回転は「同じ物体が
向きを変えた」という 3 次元の言い方で、Flatlay には奥行きがないからです。
字を替えるのは帳票の言い方です。
印は行頭に置き、幅を 1 文字ぶんに固定しています(w-[1ch])。見出しの
開始位置が開閉でずれず、段が縦に読めます。右端に置くと見出しの長さで位置が
変わり、列として読めなくなります。
高さのアニメーションも持ちません。開いた瞬間に後続が下がります。
solid と soft は地色が同じため、Flatlay では同じ見え方になります。
タブとパネルが地続きになります(Tabs)
選ばれた見出しとパネルは1枚の紙です。見出し列の下辺に走る罫線は、選ばれた 見出しの下でだけ途切れ、その切れ目の向こうがパネルになります。下線1本(Raster)・ セグメンテッドの塗り面(Tactile)に続く第3の形です。
切れ目を作っているのは印ではなく、選択中の見出しが自分の下辺罫線を地色で塗る
ことです。そのため indicator slot は描画されません。実体のある印を置くと
「罫線が抜けている」ではなく「上に何か乗っている」に見えてしまいます。
見出し列とパネルの間に余白はありません。離すと罫線が繋がらず、ただ隣り合った
2つの面に戻ります。classNames で間隔を足すときはこの点に注意してください。
Radio だけが円です
角丸は 0 / 2 / 2 / 4px で統一していますが、Radio の枠と印は radius-full です
(Avatar と並ぶ2つの例外)。ここで角を守ると Checkbox と同じ形になり、
「複数選べる」のか「1つだけ」なのかが形から読めなくなります。角丸の規律より
形の弁別が優先します。
印は Checkbox の ✓ のような文字ではなく塗りの点です。円の中に字を置くと
小さすぎて読めないためで、ここだけは記号ではなく面が状態を持ちます。
枠ごと塗り潰さないのも同じ理由で、塗ると Checkbox の選択と見分けがつきません。
Select の選択済み項目に付く ▸ とは役割が違います。▸ は一覧のどれが選ばれて
いるかを読ませる印、Radio は排他選択を入力させる部品です。すでに決まった値を
表示するだけなら Radio を並べず Select を使ってください。
つまみは滑りません(Switch)
トラックもつまみも矩形で、md は 28×14px(Button の高さ 28px を横に倒した寸法)です。 両テーマのピル型に対する第3の形で、帳票の1行の中に置いても行が膨らみません。
つまみは translate で滑らず、左端と右端を即座に行き来します。滑走は
レールの上をモノが動く表現=奥行きの語彙なので、押し下げに transition を
付けないのと同じ理由でここでも使いません。変わるのは色だけです。
形が見慣れないぶん ON/OFF が読み取りにくいので、ラベルの併記を強く推奨します。
色見本は方眼に並びます(ColorPicker)
両テーマは色面を隙間を空けて並べますが、Flatlay の見本帳は升目です。列は罫線で 仕切られ、見本は升の中に収まります。升目の罫線が見本の枠を兼ねるので、スウォッチ自身は 枠を持ちません(二重に線を引くと升目が太って方眼に見えなくなります)。
選択は枠の反転です。選ばれた升目の地が紙からインクに入れ替わり、見本が台紙に
載った見え方になります。色だけに頼らないよう ✓ も併記します。
並ぶのは Stationery の8色で、赤はありません。校正の朱書きはエラー表示の道具なので、
赤は danger に予約しています。primary が danger と紛れる事故を規則ではなく世界観で
防ぐ設計です。
Card は面で持ち上がりません(罫線で仕切ります)
Flatlay の surface は bg と同値です。浮く面という概念がないので、カードの地を
背景から一段持ち上げることができません。header と footer は罫線だけで本文と
仕切ります。影も、surface トークンも使いません。
isPressable なカードは押下で全面が反転します。面積が大きいぶん派手ですが、
沈む・浮くが使えない以上ここだけ別の語彙にはできません。
角の既定は sm(2px)です。Raster の lg と違い、カードだけが大きく丸まることは
ありません。
Avatar の badge は浮きません
両テーマは在席バッジを枠の外へ absolute で貼り出しますが、Flatlay で position を
使えるのは Modal と Tooltip の2つだけです。代わりに画像と同じ升([grid-area:1/1])へ
重ね、枠の内側の下端に収めます。
重なりの順序を決めているのは DOM 順だけです。badge は root の最後の子なので上に
描かれます。z-index は使いません。
はみ出さないぶん badge は画像の一部を隠します。小さい size では sm の Badge を
渡すか、badge 自体を省いてください。
Badge の点も正方形です
withDot の点は角を持ちません。円は Radio と Avatar の2つに予約してあり、
それ以外の丸は世界観の外です。点は色だけを担うので aria-hidden を付け、
意味はラベルの文言が持ちます。
Badge の高さは 16 / 20 / 24px です。本文の行に置いても行が膨らまない寸法にしてあります。
階層は番地として読ませます(Breadcrumbs)
区切りの / は等幅で出ます。階層の列を「文」ではなく「番地」として
読ませるためで、段の深さが目で数えられ、折り返しても列がずれません。
リンクは既定で下線を持ちます。色だけで押せることを示しません。現在地は リンクにならず、色ではなく太さで示します。色を変えると「リンクの色違い」に 見えてしまい、押せるのか押せないのかが読めなくなるからです。
進捗は罫線が引かれていきます(Progress)
トラックは 1 / 2 / 4px で、両テーマより1段細くしてあります。面が伸びるのではなく
線が引かれる表現なので、地の色は subtle ではなく罫線の色です(2px の帯では
subtle と bg の明度差が見えず、線が消えます)。
不確定表示は点滅です。両テーマは細い帯を translate で往復させますが、
Flatlay は transform を持ちません。線を全幅で引いたまま濃さを脈打たせることで、
「進んでいるが量は分からない」を動かさずに示します。
回転するのは Spinner だけです
transform を使える唯一のコンポーネントです。代替(点滅・バーの往復)は
処理中であることの視認性か情報量で必ず劣り、待たされている人にとっては
世界観よりも「動いていると分かること」が先だと判断しました。
例外はここ1つで、scale や translate は Spinner でも使いません。
prefers-reduced-motion のときは回転が止まります。
Skeleton の既定は角なしです
場所取りは記入前の空欄そのものなので、中身の角丸を先取りしません
(両テーマの既定は sm)。動きは濃さのパルスだけで、シマー(グラデーションを
滑らせる光沢)は使いません。光沢は光源を前提にした表現で、影を持たない
Flatlay では成立しないためです。
押すと反転します
対話要素は押下中に面の色と文字の色が入れ替わります。スタンプを押した跡の 見え方で、沈む・浮くを使わずに押した瞬間を返します。離すと元に戻ります。
面積のある変化なので、要素が1px も動かなくても押下が伝わります。
既定で罫線を持ちます
影の無い紙面で輪郭を示せるのは線だけです。すべての variant が罫線の幅を持ち、
線の色を variant が決めます(ghost / plain だけが transparent を選ぶ)。
記号と数値は等幅です
startContent / endContent / ショートカット / 数値の slot は --novi-font-mono と
tabular-nums を既定で持ちます。ボタンや行が並んだときに幅が暴れないためです。
Flatlay は --novi-font-* を実際に上書きする初のテーマです。Web フォントは
同梱せず(依存ゼロ原則)、フォールバックスタックを精密に組んでいます。
スタイルの拡張
すべての tv() 定義を named export しています。
import { buttonStyles } from '@novi-ui/flatlay'
import { tv } from 'tailwind-variants'
// slot ベースの定義なので `base` ではなく `slots` で足す(`base` は無視されます)
const myButton = tv({
extend: buttonStyles,
slots: { root: 'uppercase tracking-widest' },
})size variant のクラスに負ける場合は、呼び出し時に渡してください。
// ✗ size の px-3 に負ける
tv({ extend: buttonStyles, slots: { root: 'px-10' } })
// ✓ 呼び出し時なら勝つ
<Button classNames={{ root: 'px-10' }} />slot
全コンポーネントは構成部位に data-slot="<名前>" を出力します。
テスト・CSS の上書き・視覚回帰がすべてこれに乗ります。
他テーマ向けに書いた classNames をそのまま渡しても壊れません。 Flatlay が
描画しない slot のキーは黙って無視されます。
日本語入力
テキスト入力と一覧選択は、IME の変換中に Enter を押しても送信・選択が起きません。
useImeSafeKeys を経由しています。
ライセンス
MIT
