weifuwu 0.55.2 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -7,6 +7,18 @@ var HttpError = class extends Error {
7
7
  this.status = status;
8
8
  }
9
9
  };
10
+ function createMiddleware(config) {
11
+ const mw = async (req, ctx, next) => {
12
+ const injected = await config.setup(ctx);
13
+ Object.assign(ctx, injected);
14
+ return next(req, ctx);
15
+ };
16
+ mw.__meta = {
17
+ injects: config.injects,
18
+ depends: config.depends ?? []
19
+ };
20
+ return mw;
21
+ }
10
22
 
11
23
  // src/core/serve.ts
12
24
  import http from "node:http";
@@ -714,7 +726,9 @@ var Router = class _Router {
714
726
  if (!next) return this._wildcardMatch(method, segments);
715
727
  node = next;
716
728
  }
717
- return this._resolveMatch(node, method, params, segments.length);
729
+ const resolved = this._resolveMatch(node, method, params, segments.length);
730
+ if (resolved) return resolved;
731
+ return this._wildcardMatch(method, segments);
718
732
  }
719
733
  _wildcardMatch(method, segments) {
720
734
  if (!this._hasWildcard) return null;
@@ -1069,23 +1083,219 @@ import { build } from "esbuild";
1069
1083
  import { readFile, stat } from "node:fs/promises";
1070
1084
  import { resolve as resolve2 } from "node:path";
1071
1085
  import { fileURLToPath } from "node:url";
1086
+
1087
+ // src/ui/html-safe.ts
1072
1088
  var HtmlSafe = class {
1089
+ value;
1073
1090
  constructor(value) {
1074
1091
  this.value = value;
1075
1092
  }
1076
- value;
1077
1093
  toString() {
1078
1094
  return this.value;
1079
1095
  }
1080
1096
  };
1097
+
1098
+ // src/client/vnode.ts
1099
+ var ASYNC_MARK = "__wfAsyncComponent";
1100
+ function isAsyncComponent(type) {
1101
+ return typeof type === "function" && type?.[ASYNC_MARK] === true;
1102
+ }
1103
+ var Fragment = /* @__PURE__ */ Symbol("Fragment");
1104
+ var Portal = /* @__PURE__ */ Symbol("Portal");
1105
+
1106
+ // src/client/reactive.ts
1107
+ function createReactiveState(dirty) {
1108
+ const proxyCache = /* @__PURE__ */ new WeakMap();
1109
+ const reactive = (target) => {
1110
+ if (target === null || typeof target !== "object") return target;
1111
+ if (proxyCache.has(target)) return proxyCache.get(target);
1112
+ const proxy = new Proxy(target, {
1113
+ set(target2, key, value) {
1114
+ const old = Reflect.get(target2, key);
1115
+ if (old === value) return true;
1116
+ Reflect.set(target2, key, value);
1117
+ dirty();
1118
+ return true;
1119
+ },
1120
+ get(target2, key) {
1121
+ const value = Reflect.get(target2, key);
1122
+ if (typeof value === "object" && value !== null) return reactive(value);
1123
+ return value;
1124
+ },
1125
+ deleteProperty(target2, key) {
1126
+ if (Reflect.has(target2, key)) {
1127
+ Reflect.deleteProperty(target2, key);
1128
+ dirty();
1129
+ }
1130
+ return true;
1131
+ }
1132
+ });
1133
+ proxyCache.set(target, proxy);
1134
+ return proxy;
1135
+ };
1136
+ return reactive({});
1137
+ }
1138
+
1139
+ // src/ui/ssr.ts
1081
1140
  function escape(s) {
1082
1141
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1083
1142
  }
1143
+ var VOID_TAGS = /* @__PURE__ */ new Set([
1144
+ "area",
1145
+ "base",
1146
+ "br",
1147
+ "col",
1148
+ "embed",
1149
+ "hr",
1150
+ "img",
1151
+ "input",
1152
+ "link",
1153
+ "meta",
1154
+ "param",
1155
+ "source",
1156
+ "track",
1157
+ "wbr"
1158
+ ]);
1159
+ function classToString(v) {
1160
+ if (typeof v === "string") return v;
1161
+ if (Array.isArray(v)) return v.filter(Boolean).join(" ");
1162
+ if (v && typeof v === "object") {
1163
+ return Object.entries(v).filter(([, b]) => b).map(([k]) => k).join(" ");
1164
+ }
1165
+ return "";
1166
+ }
1167
+ function styleToString(v) {
1168
+ if (typeof v === "string") return v;
1169
+ if (v && typeof v === "object") {
1170
+ return Object.entries(v).filter(([, x]) => x != null).map(([k, x]) => `${k}:${typeof x === "number" ? x + "px" : String(x)}`).join(";");
1171
+ }
1172
+ return "";
1173
+ }
1174
+ function createSsrContext(serverCtx, dataStore) {
1175
+ const usedIds = /* @__PURE__ */ new Set();
1176
+ const ui2 = {
1177
+ $: () => createReactiveState(() => {
1178
+ }),
1179
+ dirty: () => {
1180
+ },
1181
+ render: () => {
1182
+ },
1183
+ selfId: (name) => {
1184
+ if (usedIds.has(name)) throw new Error(`[weifuwu] Duplicate component ID: "${name}"`);
1185
+ usedIds.add(name);
1186
+ },
1187
+ useMedia: () => {
1188
+ },
1189
+ useBreakpoint: () => {
1190
+ },
1191
+ usePopupPosition: () => ({ top: 0, left: 0, refresh: () => {
1192
+ } })
1193
+ };
1194
+ const ctx = Object.create(serverCtx ?? {});
1195
+ ctx.ui = ui2;
1196
+ ctx.data = {
1197
+ async get(key, fetcher) {
1198
+ if (dataStore.has(key)) return dataStore.get(key);
1199
+ if (!fetcher) return void 0;
1200
+ const val = await fetcher();
1201
+ dataStore.set(key, val);
1202
+ return val;
1203
+ },
1204
+ set(key, val) {
1205
+ dataStore.set(key, val);
1206
+ },
1207
+ has(key) {
1208
+ return dataStore.has(key);
1209
+ }
1210
+ };
1211
+ return ctx;
1212
+ }
1213
+ async function renderSsr(input, ctx) {
1214
+ if (input == null || typeof input === "boolean") return "";
1215
+ if (typeof input === "string" || typeof input === "number") return escape(String(input));
1216
+ if (Array.isArray(input)) {
1217
+ let s = "";
1218
+ for (const c of input) s += await renderSsr(c, ctx);
1219
+ return s;
1220
+ }
1221
+ const vnode = input;
1222
+ if (vnode.type === Portal) return renderSsr(vnode.props?.children, ctx);
1223
+ if (vnode.type === Fragment) return renderSsr(vnode.props?.children, ctx);
1224
+ if (typeof vnode.type === "function") {
1225
+ let def;
1226
+ if (isAsyncComponent(vnode.type)) {
1227
+ def = await vnode.type(ctx);
1228
+ if (typeof def !== "function") {
1229
+ throw new Error(
1230
+ `asyncComponent factory <${vnode.type.name || "anonymous"}> must return a Component (initProps, ctx) => (props) => VNode.`
1231
+ );
1232
+ }
1233
+ } else {
1234
+ def = vnode.type;
1235
+ }
1236
+ const childCtx = Object.create(ctx);
1237
+ const renderFn = def(vnode.props ?? {}, childCtx);
1238
+ if (typeof renderFn !== "function") {
1239
+ throw new Error(
1240
+ `Component ${vnode.type.name || "anonymous"} must return a render function. Use (init_props, ctx) => (props) => VNode pattern.`
1241
+ );
1242
+ }
1243
+ return renderSsr(renderFn(vnode.props ?? {}), childCtx);
1244
+ }
1245
+ const tag = vnode.type;
1246
+ const props = vnode.props ?? {};
1247
+ const attrs = [];
1248
+ let innerHTML;
1249
+ for (const [key, value] of Object.entries(props)) {
1250
+ if (key === "children" || key === "key") continue;
1251
+ if (key === "ref") continue;
1252
+ if (key.startsWith("on") && typeof value === "function") continue;
1253
+ if (key === "innerHTML") {
1254
+ innerHTML = String(value ?? "");
1255
+ continue;
1256
+ }
1257
+ if (key === "class" || key === "className") {
1258
+ const cls = classToString(value);
1259
+ if (cls) attrs.push(` class="${escape(cls)}"`);
1260
+ continue;
1261
+ }
1262
+ if (key === "style" && value && typeof value === "object") {
1263
+ attrs.push(` style="${escape(styleToString(value))}"`);
1264
+ continue;
1265
+ }
1266
+ if (value === true) {
1267
+ attrs.push(` ${key}`);
1268
+ continue;
1269
+ }
1270
+ if (value === false || value == null) continue;
1271
+ attrs.push(` ${key}="${escape(String(value))}"`);
1272
+ }
1273
+ const attrStr = attrs.join("");
1274
+ if (VOID_TAGS.has(tag)) return `<${tag}${attrStr}>`;
1275
+ if (innerHTML !== void 0) return `<${tag}${attrStr}>${innerHTML}</${tag}>`;
1276
+ const children = await renderSsr(props.children, ctx);
1277
+ return `<${tag}${attrStr}>${children}</${tag}>`;
1278
+ }
1279
+ async function ssrToString(Comp, props, serverCtx, opts = {}) {
1280
+ const dataStore = opts.data ?? /* @__PURE__ */ new Map();
1281
+ const ctx = createSsrContext(serverCtx, dataStore);
1282
+ const html = await renderSsr({ type: Comp, props: props ?? {}, key: void 0 }, ctx);
1283
+ return new HtmlSafe(html);
1284
+ }
1285
+ function serializeData(data) {
1286
+ const json = JSON.stringify(Object.fromEntries(data)).replace(/</g, "\\u003c");
1287
+ return `<script>window.__DATA__=${json};</script>`;
1288
+ }
1289
+
1290
+ // src/ui/index.ts
1291
+ function escape2(s) {
1292
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1293
+ }
1084
1294
  function stringify(v) {
1085
1295
  if (v == null || v === false || v === true) return "";
1086
1296
  if (Array.isArray(v)) return v.map(stringify).join("");
1087
1297
  if (v instanceof HtmlSafe) return v.value;
1088
- return escape(String(v));
1298
+ return escape2(String(v));
1089
1299
  }
1090
1300
  function unsafe(s) {
1091
1301
  return new HtmlSafe(s);
@@ -1139,12 +1349,21 @@ function ui() {
1139
1349
  }
1140
1350
  ctx.ui = {
1141
1351
  html: Object.assign(htmlTag, { unsafe }),
1352
+ /** SSR 渲染组件 → HTML 片段(HtmlSafe:内联进 ctx.ui.html 不二次转义) */
1353
+ async ssr(Comp, props, opts) {
1354
+ const safe = await ssrToString(Comp, props ?? {}, ctx, opts);
1355
+ return safe;
1356
+ },
1357
+ /** 序列化 SSR 数据存储 → window.__DATA__ 脚本(HtmlSafe) */
1358
+ ssrData(data) {
1359
+ return new HtmlSafe(serializeData(data));
1360
+ },
1142
1361
  async js(entryPath) {
1143
1362
  const absPath = resolveEntry(entryPath);
1144
1363
  const cached = jsCache.get(absPath);
1145
1364
  if (cached && await jsCacheFresh(cached.inputs)) {
1146
1365
  return new Response(cached.code, {
1147
- headers: { "Content-Type": "application/javascript" }
1366
+ headers: { "Content-Type": "application/javascript", "Cache-Control": "no-cache" }
1148
1367
  });
1149
1368
  }
1150
1369
  const result = await build({
@@ -1168,7 +1387,7 @@ function ui() {
1168
1387
  }
1169
1388
  jsCache.set(absPath, { code, inputs });
1170
1389
  return new Response(code, {
1171
- headers: { "Content-Type": "application/javascript" }
1390
+ headers: { "Content-Type": "application/javascript", "Cache-Control": "no-cache" }
1172
1391
  });
1173
1392
  },
1174
1393
  async css(entryPath) {
@@ -1177,7 +1396,7 @@ function ui() {
1177
1396
  const cached = cssCache.get(absPath);
1178
1397
  if (cached && cached.mtime === st.mtimeMs) {
1179
1398
  return new Response(cached.code, {
1180
- headers: { "Content-Type": "text/css; charset=utf-8" }
1399
+ headers: { "Content-Type": "text/css; charset=utf-8", "Cache-Control": "no-cache" }
1181
1400
  });
1182
1401
  }
1183
1402
  let code = await readFile(absPath, "utf-8");
@@ -1195,7 +1414,7 @@ function ui() {
1195
1414
  }
1196
1415
  cssCache.set(absPath, { code, mtime: st.mtimeMs });
1197
1416
  return new Response(code, {
1198
- headers: { "Content-Type": "text/css; charset=utf-8" }
1417
+ headers: { "Content-Type": "text/css; charset=utf-8", "Cache-Control": "no-cache" }
1199
1418
  });
1200
1419
  }
1201
1420
  };
@@ -1203,6 +1422,98 @@ function ui() {
1203
1422
  };
1204
1423
  }
1205
1424
 
1425
+ // src/client/route-match.ts
1426
+ function joinPaths(a, b) {
1427
+ if (!b || b === "/") return a || "/";
1428
+ const left = a.endsWith("/") ? a.slice(0, -1) : a;
1429
+ const right = b.startsWith("/") ? b : "/" + b;
1430
+ return left + right;
1431
+ }
1432
+ function compilePath(path) {
1433
+ const keys = [];
1434
+ const reStr = path.replace(/:(\w+)/g, (_, key) => {
1435
+ keys.push(key);
1436
+ return "([^/]+)";
1437
+ }).replace(/\*/g, ".*");
1438
+ return { re: new RegExp(`^${reStr}$`), keys };
1439
+ }
1440
+ function flattenRoutes(routes, basePath = "", chain = []) {
1441
+ const result = [];
1442
+ for (const route of routes) {
1443
+ const fullPath = joinPaths(basePath, route.path);
1444
+ const { re, keys } = compilePath(fullPath);
1445
+ const fullChain = [...chain, route];
1446
+ result.push({ re, keys, def: route, chain: fullChain });
1447
+ if (route.children) {
1448
+ result.push(...flattenRoutes(route.children, fullPath, fullChain));
1449
+ }
1450
+ }
1451
+ return result;
1452
+ }
1453
+ function matchRoute(path, routes) {
1454
+ let best = null;
1455
+ for (const fr of routes) {
1456
+ if (path.match(fr.re)) {
1457
+ if (!best || fr.chain.length > best.chain.length) best = fr;
1458
+ }
1459
+ }
1460
+ return best;
1461
+ }
1462
+ function extractParams(path, match) {
1463
+ const params = {};
1464
+ const m = path.match(match.re);
1465
+ if (m) {
1466
+ for (let i = 0; i < match.keys.length; i++) {
1467
+ params[match.keys[i]] = decodeURIComponent(m[i + 1]);
1468
+ }
1469
+ }
1470
+ return params;
1471
+ }
1472
+
1473
+ // src/ui/ssr-page.ts
1474
+ var DEFAULT_TEMPLATE = (p) => `<!DOCTYPE html>
1475
+ <html lang="zh-CN">
1476
+ <head>
1477
+ <meta charset="UTF-8">
1478
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1479
+ <title>${escapeHtml(p.title)}</title>
1480
+ ${(p.styles ?? []).map((s) => `<link rel="stylesheet" href="${escapeHtml(s)}">`).join("\n ")}
1481
+ </head>
1482
+ <body>
1483
+ <div id="root">${p.html}</div>
1484
+ ${p.dataScript}
1485
+ ${p.bundle ? `<script src="${escapeHtml(p.bundle)}"></script>` : ""}
1486
+ </body>
1487
+ </html>`;
1488
+ function escapeHtml(s) {
1489
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1490
+ }
1491
+ function uiSsr(opts) {
1492
+ const flatRoutes = flattenRoutes(opts.routes);
1493
+ const template = opts.template ?? DEFAULT_TEMPLATE;
1494
+ return async (req, ctx, next) => {
1495
+ if (req.method !== "GET") return next(req, ctx);
1496
+ const url = new URL(req.url);
1497
+ const match = matchRoute(url.pathname, flatRoutes);
1498
+ if (!match) return next(req, ctx);
1499
+ const params = extractParams(url.pathname, match);
1500
+ const def = match.chain[match.chain.length - 1] ?? match.def;
1501
+ const pageCtx = Object.assign(Object.create(ctx), {
1502
+ params,
1503
+ route: { path: url.pathname, params, query: Object.fromEntries(url.searchParams) }
1504
+ });
1505
+ if (!def.component) return next(req, ctx);
1506
+ const data = /* @__PURE__ */ new Map();
1507
+ const html = await ssrToString(def.component, {}, pageCtx, { data });
1508
+ const title = opts.title ? opts.title({ params, def }) : def.title || "weifuwu";
1509
+ const dataScript = new HtmlSafe(serializeData(data)).toString();
1510
+ const body = template({ html: html.toString(), dataScript, title, bundle: opts.bundle, styles: opts.styles });
1511
+ return new Response(body, {
1512
+ headers: { "Content-Type": "text/html; charset=utf-8" }
1513
+ });
1514
+ };
1515
+ }
1516
+
1206
1517
  // src/db/postgres/connection.ts
1207
1518
  import net from "node:net";
1208
1519
  import crypto2 from "node:crypto";
@@ -2933,6 +3244,7 @@ export {
2933
3244
  badRequest,
2934
3245
  conflict,
2935
3246
  cors,
3247
+ createMiddleware,
2936
3248
  created,
2937
3249
  forbidden,
2938
3250
  noContent,
@@ -2947,6 +3259,7 @@ export {
2947
3259
  serverError,
2948
3260
  tooManyRequests,
2949
3261
  ui,
3262
+ uiSsr,
2950
3263
  unauthorized,
2951
3264
  unprocessable
2952
3265
  };
@@ -35,8 +35,20 @@
35
35
  --wf-shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1);
36
36
 
37
37
  /* ── 层级 ── */
38
- --wf-pop-z: 50;
39
- --wf-cover-z: 100;
38
+ /* 浮层全部 createPortal 到 body,全局参与堆叠。
39
+ 顺序:sticky < overlay < modal/drawer < 弹出面板族 < toast < tooltip。
40
+ modal 与 drawer 互斥出现;面板族同级(dropdown/select/datepicker/popover)用 DOM 顺序裁决。 */
41
+ --wf-z-sticky: 40;
42
+ --wf-z-overlay: 80;
43
+ --wf-z-modal: 100;
44
+ --wf-z-popover: 120; /* dropdown/select/datepicker 面板与 popover 同层 */
45
+ --wf-z-toast: 140;
46
+ --wf-z-tooltip: 160;
47
+ --wf-z-dropdown: var(--wf-z-popover); /* 语义别名:下拉菜单属弹出面板族 */
48
+
49
+ /* ── 兼容别名(旧 token 名,语义近似映射) ── */
50
+ --wf-pop-z: var(--wf-z-popover);
51
+ --wf-cover-z: var(--wf-z-modal);
40
52
 
41
53
  /* ── 色彩 — 品牌 ── */
42
54
  --wf-color-primary: #4f6ef7;
@@ -71,15 +83,15 @@
71
83
  --wf-font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
72
84
  --wf-font-mono: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace;
73
85
 
74
- --wf-font-size-xs: 11px;
75
- --wf-font-size-sm: 12px;
76
- --wf-font-size-base: 13px;
77
- --wf-font-size-lg: 14px;
78
- --wf-font-size-xl: 15px;
79
- --wf-font-size-2xl: 20px;
86
+ --wf-font-size-xs: 12px;
87
+ --wf-font-size-sm: 13px;
88
+ --wf-font-size-base: 14px;
89
+ --wf-font-size-lg: 15px;
90
+ --wf-font-size-xl: 16px;
91
+ --wf-font-size-2xl: 21px;
80
92
  --wf-font-size-3xl: 24px;
81
- --wf-font-size-4xl: 28px;
82
- --wf-font-size-5xl: 32px;
93
+ --wf-font-size-4xl: 30px;
94
+ --wf-font-size-5xl: 36px;
83
95
 
84
96
  --wf-font-weight-normal: 400;
85
97
  --wf-font-weight-medium: 500;
@@ -110,6 +122,10 @@
110
122
  --wf-accent-color: var(--wf-color-primary);
111
123
  --wf-caret-color: var(--wf-color-primary);
112
124
 
125
+ /* 控件尺寸:垂直内边距 + 目标高度(移动端在 _base.css 提升到 44px) */
126
+ --wf-control-pad-y: 10px;
127
+ --wf-control-height: 36px;
128
+
113
129
  /* ── 断点 ── */
114
130
  --wf-bp-sm: 640px;
115
131
  --wf-bp-md: 768px;
@@ -126,8 +142,21 @@
126
142
  --wf-letter-spacing-wider: 1px;
127
143
  }
128
144
 
129
- /* weifuwu/theme — 暗色模式 */
130
- [data-theme="dark"] {
145
+ /* weifuwu/theme — 暗色模式
146
+ *
147
+ * 两种激活方式(优先级:显式 data-theme > 系统偏好):
148
+ * 1. 手动:<html data-theme="dark">(或 data-theme="light" 强制亮色)
149
+ * 2. 自动:系统偏好 prefers-color-scheme: dark,无显式覆盖时生效
150
+ *
151
+ * ⚠️ 两段暗色覆盖值必须保持一致(静态 CSS 无法共享选择器列表,改动需同步)。
152
+ * 每段末尾都设置 color-scheme: dark,让原生控件/滚动条跟随。
153
+ */
154
+
155
+ /* ── 激活方式一:手动 data-theme="dark" ── */
156
+ /* color-scheme 用 :root[data-theme] 提升优先级,压过 _base.css 的 :root 定义(导入顺序在后者) */
157
+ :root[data-theme="dark"] {
158
+ color-scheme: dark;
159
+
131
160
  --wf-color-primary: #6b8aff;
132
161
  --wf-color-primary-hover: #8ba3ff;
133
162
  --wf-color-primary-bg: #1e293b;
@@ -166,6 +195,50 @@
166
195
  --wf-surface-bg: var(--wf-color-bg);
167
196
  }
168
197
 
198
+ /* ── 激活方式二:系统暗色偏好(data-theme="light" 可强制亮色) ── */
199
+ @media (prefers-color-scheme: dark) {
200
+ :root:not([data-theme="light"]) {
201
+ color-scheme: dark;
202
+
203
+ --wf-color-primary: #6b8aff;
204
+ --wf-color-primary-hover: #8ba3ff;
205
+ --wf-color-primary-bg: #1e293b;
206
+
207
+ --wf-color-success: #4ade80;
208
+ --wf-color-success-bg: #14532d;
209
+ --wf-color-warning: #fbbf24;
210
+ --wf-color-warning-bg: #451a03;
211
+ --wf-color-error: #f87171;
212
+ --wf-color-error-bg: #451a1a;
213
+
214
+ --wf-color-info: #60a5fa;
215
+ --wf-color-info-bg: #172554;
216
+
217
+ --wf-color-text: #f1f5f9;
218
+ --wf-color-text-secondary: #94a3b8;
219
+ --wf-color-text-tertiary: #64748b;
220
+ --wf-color-text-disabled: #475569;
221
+
222
+ --wf-color-bg: #0f172a;
223
+ --wf-color-bg-secondary: #1e293b;
224
+ --wf-color-bg-tertiary: #334155;
225
+
226
+ --wf-color-border: #334155;
227
+ --wf-color-border-light: #1e293b;
228
+ --wf-color-border-dark: #475569;
229
+
230
+ --wf-surface-shadow: 0 1px 3px rgba(0,0,0,0.3);
231
+ --wf-shadow-sm: 0 1px 2px rgba(0,0,0,0.2);
232
+ --wf-shadow: 0 1px 3px rgba(0,0,0,0.3);
233
+ --wf-shadow-md: 0 4px 12px rgba(0,0,0,0.4);
234
+ --wf-shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.4);
235
+
236
+ --wf-focus-ring: 0 0 0 2px var(--wf-color-primary-bg);
237
+
238
+ --wf-surface-bg: var(--wf-color-bg);
239
+ }
240
+ }
241
+
169
242
  /* weifuwu/theme — 基础元素默认样式 */
170
243
  *,
171
244
  *::before,
@@ -173,6 +246,11 @@
173
246
  box-sizing: border-box;
174
247
  }
175
248
 
249
+ :root {
250
+ /* 原生控件/滚动条跟随主题;暗色激活时在 _dark.css 中切为 dark */
251
+ color-scheme: light;
252
+ }
253
+
176
254
  body {
177
255
  margin: 0;
178
256
  background: var(--wf-color-bg);
@@ -210,7 +288,7 @@ select {
210
288
  color: var(--wf-color-text);
211
289
  border: var(--wf-border-width) solid var(--wf-color-border);
212
290
  border-radius: var(--wf-radius);
213
- padding: var(--wf-space-sm) var(--wf-space);
291
+ padding: var(--wf-control-pad-y) var(--wf-space);
214
292
  }
215
293
 
216
294
  input:focus,
@@ -235,7 +313,8 @@ button {
235
313
  color: var(--wf-color-text);
236
314
  border: var(--wf-border-width) solid var(--wf-color-border);
237
315
  border-radius: var(--wf-radius);
238
- padding: var(--wf-space-sm) var(--wf-space-md);
316
+ padding: var(--wf-control-pad-y) var(--wf-space-md);
317
+ min-height: var(--wf-control-height);
239
318
  cursor: pointer;
240
319
  }
241
320
  button:hover {
@@ -243,6 +322,31 @@ button:hover {
243
322
  color: var(--wf-color-primary);
244
323
  }
245
324
 
325
+ /* 键盘焦点:button 与 [tabindex] 统一 focus ring(仅键盘触发,鼠标点击不闪) */
326
+ button:focus-visible,
327
+ [tabindex]:focus-visible {
328
+ outline: none;
329
+ box-shadow: var(--wf-focus-ring);
330
+ }
331
+
332
+ /* 触屏设备(coarse pointer):交互目标提升到 44px(Apple HIG / WCAG 2.5.8) */
333
+ @media (pointer: coarse) {
334
+ button,
335
+ [role="button"],
336
+ input,
337
+ textarea,
338
+ select,
339
+ .wf-card--clickable,
340
+ .wf-table-th--sortable,
341
+ .wf-accordion-summary,
342
+ .wf-tag-close,
343
+ .wf-checkbox,
344
+ .wf-switch,
345
+ .wf-radio {
346
+ min-height: 44px;
347
+ }
348
+ }
349
+
246
350
  h1, h2, h3, h4, h5, h6 {
247
351
  font-weight: var(--wf-font-weight-semibold);
248
352
  line-height: var(--wf-line-height-tight);
@@ -314,6 +418,18 @@ code {
314
418
  border-radius: var(--wf-radius-sm);
315
419
  }
316
420
 
421
+ /* ── 无障碍:尊重系统减弱动效偏好 ── */
422
+ @media (prefers-reduced-motion: reduce) {
423
+ *,
424
+ *::before,
425
+ *::after {
426
+ animation-duration: 0.01ms !important;
427
+ animation-iteration-count: 1 !important;
428
+ transition-duration: 0.01ms !important;
429
+ scroll-behavior: auto !important;
430
+ }
431
+ }
432
+
317
433
  /* stack — 纵向堆叠 */
318
434
  .wf-stack {
319
435
  display: flex;
@@ -558,7 +674,9 @@ code {
558
674
  min-height: 0;
559
675
  }
560
676
 
561
- /* text-align 对齐原语 */
677
+ /* 排版工具类 — 字号 / 色阶 / 字重 / 截断(零 CSS 用户自排版) */
678
+
679
+ /* ── text-align ── */
562
680
 
563
681
  .wf-text-left {
564
682
  text-align: left;
@@ -571,3 +689,50 @@ code {
571
689
  .wf-text-right {
572
690
  text-align: right;
573
691
  }
692
+
693
+ /* ── 字号 ── */
694
+
695
+ .wf-text-xs { font-size: var(--wf-font-size-xs); }
696
+ .wf-text-sm { font-size: var(--wf-font-size-sm); }
697
+ .wf-text-base { font-size: var(--wf-font-size-base); }
698
+ .wf-text-lg { font-size: var(--wf-font-size-lg); }
699
+ .wf-text-xl { font-size: var(--wf-font-size-xl); }
700
+ .wf-text-2xl { font-size: var(--wf-font-size-2xl); }
701
+ .wf-text-3xl { font-size: var(--wf-font-size-3xl); }
702
+ .wf-text-4xl { font-size: var(--wf-font-size-4xl); }
703
+ .wf-text-5xl { font-size: var(--wf-font-size-5xl); }
704
+
705
+ /* ── 色阶 ── */
706
+
707
+ .wf-text-secondary { color: var(--wf-color-text-secondary); }
708
+ .wf-text-tertiary { color: var(--wf-color-text-tertiary); }
709
+ .wf-text-disabled { color: var(--wf-color-text-disabled); }
710
+ .wf-text-brand { color: var(--wf-color-primary); }
711
+
712
+ /* ── 字重 ── */
713
+
714
+ .wf-text-medium { font-weight: var(--wf-font-weight-medium); }
715
+ .wf-text-semibold { font-weight: var(--wf-font-weight-semibold); }
716
+ .wf-text-bold { font-weight: var(--wf-font-weight-bold); }
717
+
718
+ /* ── 截断 ── */
719
+
720
+ .wf-truncate {
721
+ overflow: hidden;
722
+ text-overflow: ellipsis;
723
+ white-space: nowrap;
724
+ }
725
+
726
+ .wf-line-clamp-2 {
727
+ display: -webkit-box;
728
+ -webkit-line-clamp: 2;
729
+ -webkit-box-orient: vertical;
730
+ overflow: hidden;
731
+ }
732
+
733
+ .wf-line-clamp-3 {
734
+ display: -webkit-box;
735
+ -webkit-line-clamp: 3;
736
+ -webkit-box-orient: vertical;
737
+ overflow: hidden;
738
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * HtmlSafe — 标记"已是安全 HTML"的字符串(SSR 输出/unsafe 用户输入)。
3
+ *
4
+ * ctx.ui.html 的 stringify 对插值默认转义;instanceof HtmlSafe 的值原样内联。
5
+ * 与 ctx.ui.ssr 的产物共用同一类,保证模板内联不二次转义。
6
+ */
7
+ export declare class HtmlSafe {
8
+ value: string;
9
+ constructor(value: string);
10
+ toString(): string;
11
+ }