主题定制

所有组件读的都是同一批自定义属性,所以所谓主题不过是一组写在 :root 上的覆盖——没有构建步骤,没有配置文件,也没有哪个组件需要被告知这件事。

把样式放进来

styles() 返回一个指向单个文件的 <link>,浏览器会在整站各页面之间缓存它。没什么要配置的,也没什么要复制的:sitelo 的插件在 dev 里提供它,构建时把它写进产物,位置和组件运行时同一个基路径。

import { styles } from 'sitelo/ui'

head(
  title('我的站点'),
  styles(),
)
// <link rel="stylesheet" href="/su/ui-c9428b65.css">

文件名里带着内容的哈希,所以你可以按 immutable 提供它,同时照样能发布改动。传 { hash: false } 就得到一个朴素的 /su/ui.css,传 base 则把链接指向你自己托管的那一份。

{ inline: true } 改为把整份样式表放进一个 <style>:每一页多出大约 11 kB(gzip 后),但不用多一次请求,也不会有东西从 dist/ 里漏掉。对单页来说这更划算;而访客读到第二页时,链接就把那次请求赚回来了。

head(
  title('我的站点'),
  styles({ inline: true }),
)

这一族里的其余函数把零件交给你:stylesheet() 返回字符串形式的原始 CSS,方便你把它放到 sitelo 够不着的地方,或是自己写到别处去;stylesUrl() 只给出这个 URL,方便你自己写 link 元素。

预设

预设一次就改掉整体观感。styles({ preset: 'neumorphism' }) 会在核心样式表之后紧接着链接第二份样式表——服务、哈希和缓存的方式都一样——页面上的每个组件都会跟着变,标记一处都不用改。

import { styles } from 'sitelo/ui'

head(
  title('我的站点'),
  styles({ preset: 'neumorphism' }),
)
// <link rel="stylesheet" href="/su/ui-c9428b65.css">
// <link rel="stylesheet" href="/su/neumorphism-5d0e7b91.css">

每个预设都有自己的页面,所有组件都在上面实时重新设计:新拟态、新粗野主义, Superneon。

inline 会把两份样式表都内联,stylesheet({ preset }) 把它们作为一个字符串返回;传入一个不是预设的名字会抛错,并列出现有的预设。

覆盖令牌

theme() 负责写下这些覆盖。键可以是 camelCase 的令牌名、调色板对象,或者字面量自定义属性——而且它排在 styles() 之后,所以它说了算。

import { styles, theme } from 'sitelo/ui'

head(
  styles(),
  theme({
    primary: { base: '#5b5bd6', hover: '#4a4ac4', active: '#3f3fb0', fg: '#ffffff' },
    radiusMd: '2px',
    fontSans: '"Inter", system-ui, sans-serif',
  }),
)

限定范围的主题

给一个 selector,覆盖就只作用于某棵子树,而不是整个页面。下面这三块面板就是这么做的——同样的组件、三套不同配色,同处一页。

靛蓝
粉色
圆角
fragment(
  theme({ primary: { base: '#5b5bd6', hover: '#4a4ac4', fg: '#ffffff', soft: '#e6e6fa', softFg: '#33338f', border: '#b9b9ee' } }, { selector: '.theme-indigo' }),
  theme({ primary: { base: '#b0357a', hover: '#962e68', fg: '#ffffff', soft: '#fbe4f0', softFg: '#7d1f53', border: '#f0a9ce' } }, { selector: '.theme-pink' }),
  theme({ radiusMd: '999px', radiusLg: '1.5rem' }, { selector: '.theme-round' }),
  grid({ min: '11rem' },
    div({ class: 'theme-indigo' },
      card(cardBody(stack({ gap: 'sm' },
        text({ variant: 'caption', tone: 'muted' }, '靛蓝'),
        button({ block: true }, 'Primary'),
        button({ variant: 'soft', block: true }, 'Soft'),
      ))),
    ),
    div({ class: 'theme-pink' },
      card(cardBody(stack({ gap: 'sm' },
        text({ variant: 'caption', tone: 'muted' }, '粉色'),
        button({ block: true }, 'Primary'),
        button({ variant: 'soft', block: true }, 'Soft'),
      ))),
    ),
    div({ class: 'theme-round' },
      card(cardBody(stack({ gap: 'sm' },
        text({ variant: 'caption', tone: 'muted' }, '圆角'),
        button({ block: true }, 'Primary'),
        button({ variant: 'soft', block: true }, 'Soft'),
      ))),
    ),
  ),
)

深色模式

深色会自己根据 prefers-color-scheme 判断。任意祖先元素上显式写着 light 或 dark 的 data-theme 或 data-su-theme 会盖过它——本站的示例就是这样跟着顶栏那个开关走的。

只想在深色下生效的覆盖,就传 dark。它一次把属性和媒体查询都照顾到。

theme({
  primary: { base: '#5b5bd6' },
}, {
  dark: { primary: { base: '#8f8ff0' } },
})

有哪些可以覆盖

五套调色板、每套九个槽位,一套间距尺度,还有字体、圆角、阴影和各种表面色。它们每一个都是自定义属性——打开样式表,或者浏览器的检查器,全都挂在 :root 上。

primary
neutral
success
warning
danger
xs
sm
md
lg
xl
stack({ gap: 'md' },
  stack({ direction: 'row', gap: 'sm', wrap: true },
    ...['primary', 'neutral', 'success', 'warning', 'danger'].map((color) =>
      stack({ gap: 'xs', align: 'center' },
        div({ style: 'width: 3.5rem; height: 2rem; border-radius: 0.4rem; background: var(--su-' + color + ')' }),
        text({ variant: 'caption', tone: 'muted' }, color),
      ),
    ),
  ),
  stack({ direction: 'row', gap: 'sm', wrap: true, align: 'flex-end' },
    ...['xs', 'sm', 'md', 'lg', 'xl'].map((step) =>
      stack({ gap: 'xs', align: 'center' },
        div({ style: 'width: var(--su-space-' + step + '); height: 2rem; border-radius: 0.2rem; background: var(--su-neutral)' }),
        text({ variant: 'caption', tone: 'muted' }, step),
      ),
    ),
  ),
)

命名规则

camelCase 的键会变成 kebab-case 的属性:radiusMd 就是 --su-radius-md,fontSans 就是 --su-font-sans。嵌套对象也按同样方式展开——{ primary: { softFg: … } } 设置的是 --su-primary-soft-fg。而已经以 -- 开头的键会原样使用,这就是那条留给映射规则覆盖不到之处的后门。

一套调色板有九个槽位:base、hover、active、fg、soft、softHover、softFg、border 和 ring。只写你要改的那几个就行。

对比度

随库附带的调色板在两种主题下都能相对其所处的表面达到 WCAG AA,仓库里还有一个测试,一旦这条不再成立就会让构建失败。你自己的主题不在它的覆盖范围内——发布之前,请自行核对你的 fg 与 base 的对比。

属性

styles():

属性类型默认值说明
preset'neumorphism' | 'neubrutalism' | 'superneon'—用预设重新设计每个组件,链接或内联在核心样式表之后。
inlinebooleanfalse直接产出 CSS 本身,而不是指向它的链接。
hashbooleantrue在文件名里加上内容哈希。仅链接时有效。
basestring'/su/'把 URL 指向别处;那份文件由你自己托管。仅链接时有效。
minifybooleantrue去掉注释和空白。仅内联时有效。
noncestring—给产出的元素用的 CSP nonce。

stylesUrl() 接受 base 和 hash;stylesheet() 接受 minify 和 preset。

theme(tokens, options):

属性类型默认值说明
selectorstring':root'把这些覆盖限定在某棵子树内。
darkobject—只在深色模式下生效的覆盖。
noncestring—CSP nonce。