diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f7a23..7a5e985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,13 @@ The project publishes 0.x prerelease versions; a stable release line is not yet ### Fixed +- Follow the shared design language for reading, numeric and action alignment; generate the existing Web color variables from a pinned design-system token snapshot, and use a single consistent empty-state pattern. Refs #211. + +- Improve web caption and status contrast in both themes, including tinted danger + buttons, and center action labels, context menus, badges, dialog prompts, and + overview/detail headings. Restore localized cancel/confirm labels when a + confirmation dialog caller omits custom action text. + - Every MinIO image reference in the test stack, the local development stack and the self-hosted single-node Compose profile now resolves from `quay.io` instead of Docker Hub. MinIO stopped publishing container images in October 2025 and diff --git a/web/design-system/LICENSE b/web/design-system/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/web/design-system/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/web/design-system/README.md b/web/design-system/README.md new file mode 100644 index 0000000..1c448bf --- /dev/null +++ b/web/design-system/README.md @@ -0,0 +1,37 @@ +# Shared design token bridge + +`tokens.json` is an unmodified snapshot of +[`bytefolk/design-system` at 910456901dda74da4d5b0320cd03d36ad18650b0](https://github.com/bytefolk/design-system/blob/910456901dda74da4d5b0320cd03d36ad18650b0/tokens/design-tokens.json). +The upstream Apache-2.0 license is included as `LICENSE`. + +The shared JSON owns the palette. Do not edit the snapshot or generated CSS by +hand. To update it, copy the JSON from a reviewed upstream commit, update the +revision and expected SHA-256 in `../scripts/design-tokens.mjs`, regenerate, and review both themes. +The CSS header records the commit and content SHA-256. + +```sh +cd web +npm run tokens:generate +npm run tokens:check +``` + +`tokens:check` runs before every Web build. The zero-dependency generator maps +the default profile to mem's existing RGB variable API, preserves alpha, and +uses shared font/shadow roles. Human actions are blue; `ai` is reserved for AI +affordances. The user's saved light/dark preference is retained. + +Upstream base hues include decorative contrast exceptions. For actual small +text, the adapter raises source alpha or adjusts an opaque source hue toward +the theme's foreground until it reaches 4.5:1 on all five source surfaces. +Semantic foregrounds also meet this ratio on up to 30% tinted state backgrounds. Solid normal/hover backgrounds +are derived from upstream primary/hover hues and paired with the upstream +primary foreground. Generation checks 334 text/background pairs; actual +browser acceptance additionally checks compositing and control states. + +mem currently uses React 19 and local primitives; the published shared facade +expects React 18. This bridge deliberately adds no runtime dependency or React +migration. The local `EmptyState` follows the shared `ui-empty-state` structure +and spacing; it can be replaced by a compatible shared release later. Page, +card, dialog and form content starts at the reading edge; numeric comparison +columns and trailing actions align to the end; buttons, badges, tabs and whole +empty panels center their content. Business routes and actions stay in mem. diff --git a/web/design-system/tokens.json b/web/design-system/tokens.json new file mode 100644 index 0000000..cccf5a2 --- /dev/null +++ b/web/design-system/tokens.json @@ -0,0 +1,483 @@ +{ + "$schema": "https://design-tokens.github.io/community-group/format/", + "name": "@fullstack-ai-infra/ui", + "description": "Ant Design aligned semantic design tokens v3 (light+dark). Values extracted from antd@5 defaultAlgorithm/darkAlgorithm on 2026-08-24 by Design Lead; AA deviations documented in docs/org-workbench-design-language.md. Single token source per ADR 0002: src/styles/tokens.css is generated via scripts/generate-tokens-css.mjs.", + "tokens": { + "color": { + "canvas": { + "$type": "color", + "$value": { + "light": "#f5f5f5", + "dark": "#000000" + } + }, + "canvas-subtle": { + "$type": "color", + "$value": { + "light": "#fafafa", + "dark": "#141414" + } + }, + "navigation": { + "$type": "color", + "$value": { + "light": "#f0f0f0", + "dark": "#1f1f1f" + } + }, + "navigation-hover": { + "$type": "color", + "$value": { + "light": "#e6f4ff", + "dark": "#15325b" + } + }, + "surface": { + "$type": "color", + "$value": { + "light": "#ffffff", + "dark": "#141414" + } + }, + "surface-raised": { + "$type": "color", + "$value": { + "light": "#ffffff", + "dark": "#1f1f1f" + } + }, + "surface-inset": { + "$type": "color", + "$value": { + "light": "#fafafa", + "dark": "#1d1d1d" + } + }, + "foreground": { + "$type": "color", + "$value": { + "light": "rgba(0, 0, 0, 0.88)", + "dark": "rgba(255, 255, 255, 0.85)" + } + }, + "foreground-muted": { + "$type": "color", + "$value": { + "light": "rgba(0, 0, 0, 0.65)", + "dark": "rgba(255, 255, 255, 0.65)" + } + }, + "foreground-subtle": { + "$type": "color", + "$value": { + "light": "rgba(0, 0, 0, 0.45)", + "dark": "rgba(255, 255, 255, 0.45)" + } + }, + "border": { + "$type": "color", + "$value": { + "light": "#d9d9d9", + "dark": "#424242" + } + }, + "border-strong": { + "$type": "color", + "$value": { + "light": "#bfbfbf", + "dark": "#595959" + } + }, + "primary": { + "$type": "color", + "$value": { + "light": "#1677ff", + "dark": "#1668dc" + } + }, + "primary-hover": { + "$type": "color", + "$value": { + "light": "#4096ff", + "dark": "#3c89e8" + } + }, + "primary-foreground": { + "$type": "color", + "$value": "#ffffff" + }, + "primary-soft": { + "$type": "color", + "$value": { + "light": "#e6f4ff", + "dark": "#15325b" + } + }, + "ai": { + "$type": "color", + "$value": { + "light": "#722ed1", + "dark": "#642ab5" + } + }, + "ai-strong": { + "$type": "color", + "$value": { + "light": "#531dab", + "dark": "#854eca" + } + }, + "ai-hover": { + "$type": "color", + "$value": { + "light": "#9254de", + "dark": "#854eca" + } + }, + "ai-foreground": { + "$type": "color", + "$value": "#ffffff" + }, + "ai-soft": { + "$type": "color", + "$value": { + "light": "#f9f0ff", + "dark": "#301c4d" + } + }, + "info": { + "$type": "color", + "$value": { + "light": "#1677ff", + "dark": "#1668dc" + } + }, + "info-soft": { + "$type": "color", + "$value": { + "light": "#e6f4ff", + "dark": "#111a2c" + } + }, + "success": { + "$type": "color", + "$value": { + "light": "#52c41a", + "dark": "#49aa19" + } + }, + "success-strong": { + "$type": "color", + "$value": { + "light": "#237804", + "dark": "#95de64" + } + }, + "success-soft": { + "$type": "color", + "$value": { + "light": "#f6ffed", + "dark": "#162312" + } + }, + "warning": { + "$type": "color", + "$value": { + "light": "#faad14", + "dark": "#d89614" + } + }, + "warning-strong": { + "$type": "color", + "$value": { + "light": "#ad4e00", + "dark": "#f8ce5b" + } + }, + "warning-soft": { + "$type": "color", + "$value": { + "light": "#fffbe6", + "dark": "#2b2111" + } + }, + "danger": { + "$type": "color", + "$value": { + "light": "#ff4d4f", + "dark": "#dc4446" + } + }, + "danger-strong": { + "$type": "color", + "$value": { + "light": "#cf1322", + "dark": "#e84749" + } + }, + "danger-soft": { + "$type": "color", + "$value": { + "light": "#fff2f0", + "dark": "#2c1618" + } + }, + "overlay": { + "$type": "color", + "$value": "rgba(0, 0, 0, 0.45)" + }, + "focus": { + "$type": "color", + "$value": { + "light": "#1677ff", + "dark": "#1668dc" + } + }, + "selection": { + "$type": "color", + "$value": { + "light": "#bae0ff", + "dark": "#15417e" + } + } + }, + "fontFamily": { + "font-sans": { + "$type": "fontFamily", + "$value": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'" + }, + "font-mono": { + "$type": "fontFamily", + "$value": "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace" + } + }, + "fontSize": { + "text-xs": { + "$type": "fontSize", + "$value": "12px" + }, + "text-sm": { + "$type": "fontSize", + "$value": "14px" + }, + "text-base": { + "$type": "fontSize", + "$value": "16px" + }, + "text-lg": { + "$type": "fontSize", + "$value": "20px" + }, + "text-xl": { + "$type": "fontSize", + "$value": "24px" + }, + "text-2xl": { + "$type": "fontSize", + "$value": "30px" + } + }, + "lineHeight": { + "leading-tight": { + "$type": "lineHeight", + "$value": "1.3333" + }, + "leading-normal": { + "$type": "lineHeight", + "$value": "1.5714" + } + }, + "dimension": { + "space-1": { + "$type": "dimension", + "$value": "0.25rem" + }, + "space-2": { + "$type": "dimension", + "$value": "0.5rem" + }, + "space-3": { + "$type": "dimension", + "$value": "0.75rem" + }, + "space-4": { + "$type": "dimension", + "$value": "1rem" + }, + "space-5": { + "$type": "dimension", + "$value": "1.25rem" + }, + "space-6": { + "$type": "dimension", + "$value": "1.5rem" + }, + "space-8": { + "$type": "dimension", + "$value": "2rem" + }, + "space-10": { + "$type": "dimension", + "$value": "2.5rem" + }, + "space-12": { + "$type": "dimension", + "$value": "3rem" + }, + "rail-width": { + "$type": "dimension", + "$value": "4.5rem" + }, + "sidebar-width": { + "$type": "dimension", + "$value": "16rem" + }, + "sidebar-wide": { + "$type": "dimension", + "$value": "18rem" + }, + "topbar-height": { + "$type": "dimension", + "$value": "3.75rem" + } + }, + "borderRadius": { + "radius-sm": { + "$type": "borderRadius", + "$value": "4px" + }, + "radius-md": { + "$type": "borderRadius", + "$value": "6px" + }, + "radius-lg": { + "$type": "borderRadius", + "$value": "8px" + }, + "radius-xl": { + "$type": "borderRadius", + "$value": "8px" + }, + "radius-full": { + "$type": "borderRadius", + "$value": "9999px" + } + }, + "boxShadow": { + "shadow-sm": { + "$type": "boxShadow", + "$value": "0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02)" + }, + "shadow-md": { + "$type": "boxShadow", + "$value": "0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)" + }, + "shadow-lg": { + "$type": "boxShadow", + "$value": "0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)" + } + }, + "duration": { + "duration-fast": { + "$type": "duration", + "$value": "0.1s" + }, + "duration-normal": { + "$type": "duration", + "$value": "0.2s" + } + }, + "timingFunction": { + "ease": { + "$type": "timingFunction", + "$value": "cubic-bezier(0.645, 0.045, 0.355, 1)" + } + } + }, + "profiles": { + "mint": { + "$description": "Compact high-contrast mint profile for dark-first workbenches. Product applications may label this profile independently.", + "tokens": { + "color": { + "canvas": { "$value": { "light": "#f6f8f7", "dark": "#0a0c10" } }, + "canvas-subtle": { "$value": { "light": "#eff3f1", "dark": "#101318" } }, + "navigation": { "$value": { "light": "#eff3f1", "dark": "#101318" } }, + "navigation-hover": { "$value": { "light": "#e5ebe8", "dark": "#181d22" } }, + "surface": { "$value": { "light": "#ffffff", "dark": "#14171d" } }, + "surface-raised": { "$value": { "light": "#ffffff", "dark": "#1a1e25" } }, + "surface-inset": { "$value": { "light": "#f1f4f2", "dark": "#101318" } }, + "foreground": { "$value": { "light": "#191e24", "dark": "#e9edf2" } }, + "foreground-muted": { "$value": { "light": "#57606b", "dark": "#a3abb8" } }, + "foreground-subtle": { "$value": { "light": "#636d78", "dark": "#7d8694" } }, + "border": { "$value": { "light": "#dbe2df", "dark": "#232830" } }, + "border-strong": { "$value": { "light": "#bfcac4", "dark": "#343b45" } }, + "primary": { "$value": { "light": "#0a6b4e", "dark": "#19d89b" } }, + "primary-hover": { "$value": { "light": "#085a42", "dark": "#2ee9a8" } }, + "primary-foreground": { "$value": { "light": "#ffffff", "dark": "#04150f" } }, + "primary-soft": { "$value": { "light": "#e2f7ef", "dark": "#0d2a22" } }, + "ai": { "$value": { "light": "#0a6b4e", "dark": "#19d89b" } }, + "ai-strong": { "$value": { "light": "#07563f", "dark": "#2ee9a8" } }, + "ai-hover": { "$value": { "light": "#085a42", "dark": "#2ee9a8" } }, + "ai-foreground": { "$value": { "light": "#ffffff", "dark": "#04150f" } }, + "ai-soft": { "$value": { "light": "#e2f7ef", "dark": "#0d2a22" } }, + "info": { "$value": { "light": "#1b7fb5", "dark": "#5cc8e8" } }, + "info-soft": { "$value": { "light": "#e6f4fb", "dark": "#12313b" } }, + "success": { "$value": { "light": "#266b4a", "dark": "#78c89c" } }, + "success-strong": { "$value": { "light": "#1e5739", "dark": "#a3ddb8" } }, + "success-soft": { "$value": { "light": "#eaf5ef", "dark": "#173526" } }, + "warning": { "$value": { "light": "#7d4f0f", "dark": "#f0b563" } }, + "warning-strong": { "$value": { "light": "#6b420b", "dark": "#f6cf95" } }, + "warning-soft": { "$value": { "light": "#fff4df", "dark": "#352816" } }, + "danger": { "$value": { "light": "#a83232", "dark": "#f08d8d" } }, + "danger-strong": { "$value": { "light": "#8f2525", "dark": "#ffb1b1" } }, + "danger-soft": { "$value": { "light": "#fff0f0", "dark": "#3b2226" } }, + "overlay": { + "$value": { "light": "rgba(20, 30, 26, 0.35)", "dark": "rgba(0, 0, 0, 0.65)" } + }, + "focus": { "$value": { "light": "#0a6b4e", "dark": "#19d89b" } }, + "selection": { "$value": { "light": "#e2f7ef", "dark": "#0d2a22" } } + }, + "fontFamily": { + "font-sans": { + "$value": "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif" + }, + "font-mono": { + "$value": "'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace" + } + }, + "fontSize": { + "text-xs": { "$value": "11px" }, + "text-sm": { "$value": "12px" }, + "text-base": { "$value": "13px" }, + "text-lg": { "$value": "17px" }, + "text-xl": { "$value": "22px" }, + "text-2xl": { "$value": "28px" } + }, + "lineHeight": { + "leading-tight": { "$value": "1.3" }, + "leading-normal": { "$value": "1.6" } + }, + "borderRadius": { + "radius-sm": { "$value": "6px" }, + "radius-md": { "$value": "8px" }, + "radius-lg": { "$value": "13px" }, + "radius-xl": { "$value": "16px" } + }, + "boxShadow": { + "shadow-sm": { "$value": "0 1px 2px 0 rgba(20, 21, 27, 0.03)" }, + "shadow-md": { + "$value": "0 4px 16px 0 rgba(20, 21, 27, 0.07), 0 1px 4px 0 rgba(20, 21, 27, 0.03)" + }, + "shadow-lg": { "$value": "0 16px 48px 0 rgba(20, 21, 27, 0.14)" } + }, + "duration": { + "duration-fast": { "$value": "0.12s" }, + "duration-normal": { "$value": "0.16s" } + }, + "timingFunction": { + "ease": { "$value": "cubic-bezier(0.22, 0.61, 0.36, 1)" } + } + } + } + } +} diff --git a/web/index.html b/web/index.html index e0268bc..a83cc1f 100644 --- a/web/index.html +++ b/web/index.html @@ -3,9 +3,9 @@ - + - + mem diff --git a/web/localization-acceptance.mjs b/web/localization-acceptance.mjs index b322272..96687c1 100644 --- a/web/localization-acceptance.mjs +++ b/web/localization-acceptance.mjs @@ -173,7 +173,7 @@ try { console.log('✓ unknown managed-embedding errors use the selected locale'); await page.goto(`${baseURL}/search`, { waitUntil: 'domcontentloaded' }); - await page.getByRole('heading', { name: '搜索' }).waitFor(); + await page.getByRole('heading', { name: '搜索', level: 1, exact: true }).waitFor(); await page.getByRole('button', { name: '草地上的金毛' }).waitFor(); assert.equal(await page.evaluate(() => window.__documentLangAtInteractive), 'zh-CN'); diff --git a/web/package.json b/web/package.json index 6a66d95..a005d33 100644 --- a/web/package.json +++ b/web/package.json @@ -21,7 +21,10 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "typecheck": "tsc -b --noEmit" + "typecheck": "tsc -b --noEmit", + "tokens:generate": "node scripts/design-tokens.mjs", + "tokens:check": "node scripts/design-tokens.mjs --check", + "prebuild": "npm run tokens:check" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.2", diff --git a/web/scripts/design-tokens.mjs b/web/scripts/design-tokens.mjs new file mode 100644 index 0000000..4899c60 --- /dev/null +++ b/web/scripts/design-tokens.mjs @@ -0,0 +1,136 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import assert from 'node:assert/strict'; + +// The snapshot, not this compatibility adapter, owns palette values. +const revision = '910456901dda74da4d5b0320cd03d36ad18650b0'; +const root = new URL('../', import.meta.url); +const source = await readFile(new URL('design-system/tokens.json', root), 'utf8'); +const tokens = JSON.parse(source).tokens; +const digest = createHash('sha256').update(source).digest('hex'); +assert.equal( + digest, + '75e62b372f23083c2f35400ce434d40bf1be098375cea85b97352dcfedbf5766', + 'Pinned upstream snapshot changed: review a new revision and its hash', +); +const value = (group, name, mode) => { + const v = tokens[group][name].$value; + return typeof v === 'string' ? v : v[mode]; +}; +const color = (name, mode) => { + const s = value('color', name, mode); + if (s.startsWith('#')) + return [ + ...s + .slice(1) + .match(/../g) + .map((v) => parseInt(v, 16)), + 1, + ]; + const c = s.match(/[\d.]+/g).map(Number); + return c.length === 3 ? [...c, 1] : c; +}; +const mix = (a, b, weight) => + a + .slice(0, 3) + .map((n, i) => Math.round(n * weight + b[i] * (1 - weight))) + .concat(1); +const luminance = (c) => + c + .slice(0, 3) + .map((v) => v / 255) + .map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)) + .reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0); +const contrast = (fg, bg) => { + const a = luminance(mix(fg, bg, fg[3] ?? 1)), + b = luminance(bg); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +}; +let css = `/* GENERATED by scripts/design-tokens.mjs; DO NOT EDIT.\n * Source: bytefolk/design-system@${revision}/tokens/design-tokens.json\n * SHA-256: ${digest}; Apache-2.0 (web/design-system/LICENSE).\n */\n`; +const checks = []; +for (const mode of ['dark', 'light']) { + const surfaces = ['canvas', 'navigation', 'surface', 'surface-inset', 'surface-raised'].map((n) => + color(n, mode), + ); + const target = mode === 'dark' ? [255, 255, 255] : [0, 0, 0]; + // Preserve the upstream hue while making actual small text readable on every + // surface. Upstream base hues are sometimes intended only for borders/icons. + const readable = (base, tinted = false) => { + for (let step = 0; step <= 100; step++) { + const c = + base[3] < 1 + ? [...base.slice(0, 3), base[3] + ((1 - base[3]) * step) / 100] + : mix(base, target, 1 - step / 100); + if ( + surfaces.every( + (bg) => contrast(c, bg) >= 4.5 && (!tinted || contrast(c, mix(c, bg, 0.3)) >= 4.5), + ) + ) + return c; + } + throw new Error('No readable foreground found'); + }; + const vars = { + bg: color('canvas', mode), + 'bg-subtle': color('navigation', mode), + 'bg-panel': color('surface', mode), + 'bg-inset': color('surface-inset', mode), + fg: color('foreground', mode), + 'fg-muted': readable(color('foreground-muted', mode)), + 'fg-subtle': readable(color('foreground-muted', mode)), + border: color('border', mode), + 'border-strong': color('border-strong', mode), + accent: readable(color('primary', mode), true), + 'accent-hover': readable(color('primary-hover', mode), true), + 'accent-muted': color('primary-soft', mode), + 'accent-solid': mix(color('primary', mode), [0, 0, 0], 0.8), + 'accent-solid-hover': mix(color('primary-hover', mode), [0, 0, 0], 0.7), + 'accent-foreground': color('primary-foreground', mode), + ai: readable(color('ai', mode), true), + success: readable(color('success-strong', mode), true), + warn: readable(color('warning-strong', mode), true), + danger: readable(color('danger-strong', mode), true), + }; + for (const name of [ + 'fg', + 'fg-muted', + 'fg-subtle', + 'accent', + 'accent-hover', + 'ai', + 'success', + 'warn', + 'danger', + ]) { + for (const bg of surfaces) { + const ratio = contrast(vars[name], bg); + assert.ok(ratio >= 4.5, `${mode} ${name}: ${ratio}`); + checks.push(ratio); + } + } + // Badges, selected navigation and danger-button hover compose tinted surfaces. + for (const name of ['accent', 'accent-hover', 'ai', 'success', 'warn', 'danger']) { + for (const bg of surfaces) + for (const alpha of [0.05, 0.1, 0.2, 0.3]) { + const ratio = contrast(vars[name], mix(vars[name], bg, alpha)); + assert.ok(ratio >= 4.5, `${mode} ${name} tint ${alpha}: ${ratio}`); + checks.push(ratio); + } + } + for (const name of ['accent-solid', 'accent-solid-hover']) { + const ratio = contrast(vars['accent-foreground'], vars[name]); + assert.ok(ratio >= 4.5, `${mode} ${name}: ${ratio}`); + checks.push(ratio); + } + css += `${mode === 'dark' ? ':root, .dark' : '.light'} {\n`; + for (const [name, c] of Object.entries(vars)) + css += ` --${name}: ${c.slice(0, 3).join(' ')};\n --${name}-opacity: ${c[3]};\n`; + css += ` --shadow-soft: ${value('boxShadow', 'shadow-sm', mode)};\n --font-sans: ${value('fontFamily', 'font-sans', mode)};\n --font-mono: ${value('fontFamily', 'font-mono', mode)};\n color-scheme: ${mode};\n}\n`; +} +const output = new URL('src/styles/design-tokens.generated.css', root); +if (process.argv.includes('--check')) + assert.equal(await readFile(output, 'utf8'), css, 'Regenerate shared design tokens'); +else await writeFile(output, css); +console.log( + `Shared token bridge: ${checks.length} contrast pairs >= ${Math.min(...checks).toFixed(2)}:1; ${process.argv.includes('--check') ? 'current' : 'generated'}`, +); diff --git a/web/src/components/explorer/ContextMenu.tsx b/web/src/components/explorer/ContextMenu.tsx index d12b0d3..24722d7 100644 --- a/web/src/components/explorer/ContextMenu.tsx +++ b/web/src/components/explorer/ContextMenu.tsx @@ -102,7 +102,7 @@ function ContextMenuView({ state, onClose }: { state: ContextMenuState; onClose: requestAnimationFrame(() => item.onSelect()); }} className={cn( - 'flex w-full items-center gap-2 px-2.5 py-1.5 text-sm rounded-sm mx-1 my-0.5', + 'grid w-[calc(100%-0.5rem)] grid-cols-[minmax(0,1fr)_auto] items-center gap-2 px-2.5 py-1.5 text-left text-sm rounded-sm mx-1 my-0.5', 'transition-colors', item.disabled ? 'text-fg-subtle cursor-not-allowed' @@ -111,9 +111,9 @@ function ContextMenuView({ state, onClose }: { state: ContextMenuState; onClose: : 'text-fg-muted hover:bg-bg-inset hover:text-fg', )} > - {item.label} + {item.label} {item.shortcut && ( - {item.shortcut} + {item.shortcut} )} diff --git a/web/src/components/explorer/FileGrid.tsx b/web/src/components/explorer/FileGrid.tsx index 458aa1c..d465cf0 100644 --- a/web/src/components/explorer/FileGrid.tsx +++ b/web/src/components/explorer/FileGrid.tsx @@ -70,9 +70,10 @@ export function FileGrid(props: FileGridProps) { return (
{pendingNewFolder && ( -
+
(
@@ -206,7 +207,7 @@ function FolderCard({ onDragLeave={() => setDropHover(false)} onDrop={onDrop} className={cn( - 'flex flex-col items-center text-center gap-2 p-2 rounded-lg cursor-default select-none', + 'flex flex-col items-center text-left gap-2 p-2 rounded-lg cursor-default select-none', 'border transition-colors', selected ? 'border-accent/60 bg-accent/10' @@ -218,6 +219,7 @@ function FolderCard({ {renaming ? ( )} -
{tt('drive.itemsN', { n: folder.fileCount })}
+
{tt('drive.itemsN', { n: folder.fileCount })}
); } @@ -265,7 +267,7 @@ function FileCard({ onDoubleClick={onDoubleClick} onContextMenu={onContextMenu} className={cn( - 'flex flex-col items-center text-center gap-2 p-2 rounded-lg cursor-default select-none', + 'flex flex-col items-center text-left gap-2 p-2 rounded-lg cursor-default select-none', 'border transition-colors', selected ? 'border-accent/60 bg-accent/10' @@ -286,7 +288,7 @@ function FileCard({ {file.index_status !== 'done' && }
{renaming ? ( - + ) : (
{file.name} @@ -307,10 +309,12 @@ function KindIcon({ kind }: { kind: FileKind }) { function StatusOverlay({ status }: { status: IndexStatus }) { const text = tt(`status.${status}`); - const tone = status === 'failed' ? 'bg-danger/80' : 'bg-bg/70'; + const tone = status === 'failed' + ? 'border border-danger/30 bg-bg-panel text-danger' + : 'bg-bg/70 text-fg'; return (
{text}
diff --git a/web/src/components/explorer/FileList.tsx b/web/src/components/explorer/FileList.tsx index 71e1396..5aaf6a7 100644 --- a/web/src/components/explorer/FileList.tsx +++ b/web/src/components/explorer/FileList.tsx @@ -33,10 +33,10 @@ export interface FileListProps { export function FileList(props: FileListProps) { const { t } = useT(); return ( -
+
{t('drive.colName')}
-
{t('drive.colSize')}
+
{t('drive.colSize')}
{t('drive.colModified')}
{t('drive.colType')}
@@ -215,7 +215,7 @@ function FolderRow({
)}
-
{tt('drive.itemsN', { n: folder.fileCount })}
+
{tt('drive.itemsN', { n: folder.fileCount })}
—
{tt('drive.folder')}
@@ -271,7 +271,7 @@ function FileRow({
)}
-
{formatBytes(file.size)}
+
{formatBytes(file.size)}
{formatRelative(file.updated_at)}
{tt(`kind.${file.kind === 'pdf' ? 'doc' : file.kind}`)}
@@ -285,4 +285,3 @@ function KindIconSmall({ kind }: { kind: FileKind }) { if (kind === 'pdf' || kind === 'doc' || kind === 'text') return ; return ; } - diff --git a/web/src/components/layout/TopBar.tsx b/web/src/components/layout/TopBar.tsx index 97bd25b..066d336 100644 --- a/web/src/components/layout/TopBar.tsx +++ b/web/src/components/layout/TopBar.tsx @@ -186,7 +186,7 @@ export function TopBar({ children }: { children?: React.ReactNode }) { @@ -166,7 +166,7 @@ export function CreateRelationDialog({ setReason(event.target.value as MemoryForgetReason)} - className="h-9 rounded-md border border-border bg-bg-inset px-3 text-sm text-fg outline-none focus:border-accent/60" + className="h-9 rounded-md border border-border bg-bg-inset px-3 text-left [text-align-last:left] text-sm text-fg outline-none focus:border-accent/60" > {REASONS.map((candidate) => (