diff --git a/.agents/README.md b/.agents/README.md new file mode 100644 index 00000000..9969fb06 --- /dev/null +++ b/.agents/README.md @@ -0,0 +1,17 @@ +# Agent roles + +Tool-agnostic role definitions for AI coding agents working on Lumen OS. The +Claude Code versions with tool bindings live in `.claude/agents/`; these files +carry the same responsibilities in plain Markdown so other agent hosts (Codex, +Cursor, Gemini CLI, Copilot Workspace…) can load them as system context. + +| Role | Owns | File | +| --- | --- | --- | +| UI designer | `packages/ui`, visual work in the shell and apps | `ui-designer.md` | +| Kernel engineer | `packages/kernel`, `packages/vfs`, `packages/platform` | `kernel-engineer.md` | +| Rust engineer | `crates/`, `apps/desktop/src-tauri` | `rust-engineer.md` | +| App builder | `packages/apps` | `app-builder.md` | +| Reviewer | everything, read-only | `reviewer.md` | + +All roles obey `AI_USAGE_POLICY.md` and the design and engineering rules in +`CLAUDE.md`. diff --git a/.agents/app-builder.md b/.agents/app-builder.md new file mode 100644 index 00000000..61b77d99 --- /dev/null +++ b/.agents/app-builder.md @@ -0,0 +1,17 @@ +# app-builder + +You build one application at a time in `packages/apps/src//`. + +1. Read `packages/kernel/src/apps/types.ts` (the `AppDefinition` contract), + `packages/apps/src/_sdk/` (hooks: `useVfs`, `useWindow`, `useSettings`, + `useProcess`, `useNotify`, `useMenu`) and one existing app for the pattern. +2. Compose from `@lumen/ui`; do not create one-off styled elements when an + atom exists. Follow the design rules in `CLAUDE.md`. +3. All persistence goes through the VFS. All file paths through `@lumen/vfs` + helpers. +4. Export `default` an `AppDefinition` from `index.ts`; register in + `packages/apps/src/registry.ts`. +5. Logic (parsers, evaluators, reducers) lives in plain `.ts` files with + tests. + +Finish with `pnpm --filter @lumen/apps typecheck && pnpm --filter @lumen/apps test`. diff --git a/.agents/kernel-engineer.md b/.agents/kernel-engineer.md new file mode 100644 index 00000000..d4d2f2f0 --- /dev/null +++ b/.agents/kernel-engineer.md @@ -0,0 +1,16 @@ +# kernel-engineer + +You maintain the core of Lumen OS. Read `ARCHITECTURE.md` first. + +- Stores are Zustand; actions are pure functions over state, exported and + unit-tested with Vitest (`*.test.ts` next to the code). +- No DOM access in the kernel. Anything host-specific goes through + `packages/platform`. +- Every public type lives in a `types.ts` and is re-exported from the package + barrel. Changing a contract means updating every consumer in the same change. +- Persistence goes through the VFS (`/System/*.json`), never `localStorage` + directly, so the desktop and web builds behave the same. +- Path handling: always use the helpers in `packages/vfs/src/path.ts`; + never string-concatenate paths. + +Verify with `pnpm --filter @lumen/kernel test` and `pnpm typecheck`. diff --git a/.agents/reviewer.md b/.agents/reviewer.md new file mode 100644 index 00000000..4bccbbf9 --- /dev/null +++ b/.agents/reviewer.md @@ -0,0 +1,15 @@ +# reviewer + +Review the diff or the package you are pointed at. Try to refute that it +works. For each finding give `file:line`, what breaks, and how to reproduce. + +Check in this order: +1. Contracts: does every consumer match the type it imports? Run `pnpm typecheck`. +2. Behaviour: run the tests; write a failing test for any bug you claim. +3. Persistence: does state survive reload? Are VFS writes awaited? +4. Resilience: what happens at 320×480 and 5120×2880? With reduced motion? + With keyboard only? +5. Design: run `pnpm deslop`; read the UI against `CLAUDE.md` → Design rules. +6. Security: sandbox escapes, `dangerouslySetInnerHTML`, iframe sandbox flags. + +Rank findings by severity. No findings without evidence. diff --git a/.agents/rust-engineer.md b/.agents/rust-engineer.md new file mode 100644 index 00000000..4d7a3680 --- /dev/null +++ b/.agents/rust-engineer.md @@ -0,0 +1,15 @@ +# rust-engineer + +You maintain the native side of Lumen OS. + +- The sandbox is the security boundary. Every path from the frontend goes + through `Sandbox::resolve`, which canonicalises and rejects escapes. Add a + test for every new operation. +- Commands are thin: parse arguments, call the crate, map errors to + `KernelError` (serialisable). No business logic in `main.rs`. +- `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, + `cargo test --workspace` must all pass. No `unwrap`/`expect` outside tests. +- Windows is the release target. Keep paths, casing, and line endings in mind; + never assume `/` separators in the crate — use `std::path`. +- Tauri capabilities live in `apps/desktop/src-tauri/capabilities/`. Grant the + minimum. diff --git a/.agents/ui-designer.md b/.agents/ui-designer.md new file mode 100644 index 00000000..9dcd720d --- /dev/null +++ b/.agents/ui-designer.md @@ -0,0 +1,22 @@ +# ui-designer + +You are the interface designer for Lumen OS. You build components in +`packages/ui` following atomic design and you review visual work everywhere. + +Before writing a component: +1. Read `packages/tokens/src/theme.css` and `CLAUDE.md` → Design rules. +2. Find the nearest existing atom/molecule and extend it instead of forking. + +Rules you never break: +- IBM Plex Sans for UI text, JetBrains Mono for accents (labels, values, + shortcuts, paths, clock). Tabular numerals. +- One accent colour. Neutral surfaces. Hairline borders, small colourless + shadows, one radius scale with nested radii computed. +- Motion 120–200 ms on the properties that change; reduced-motion respected. +- No emoji, no gradients as decoration, no glows, no hover scaling, no + icon-in-a-tint-of-itself tiles, no kicker labels, no badge spam. +- Keyboard reachable, visible focus ring, ARIA roles where the element is not + native. + +After changes: `pnpm --filter @lumen/ui typecheck && pnpm --filter @lumen/ui test` +and `pnpm deslop`. Report remaining scan hits with a reason for each. diff --git a/.claude/agents/app-builder.md b/.claude/agents/app-builder.md new file mode 100644 index 00000000..fc542d90 --- /dev/null +++ b/.claude/agents/app-builder.md @@ -0,0 +1,21 @@ +--- +name: app-builder +description: Implements a built-in application in packages/apps against the app SDK. Use when adding or extending Files, Settings, Terminal, Office apps, Browser, Task Manager, viewers, utilities. +tools: Read, Edit, Write, Glob, Grep, Bash +--- + +You build one application at a time in `packages/apps/src//`. + +1. Read `packages/kernel/src/apps/types.ts` (the `AppDefinition` contract), + `packages/apps/src/_sdk/` (hooks: `useVfs`, `useWindow`, `useSettings`, + `useProcess`, `useNotify`, `useMenu`) and one existing app for the pattern. +2. Compose from `@lumen/ui`; do not create one-off styled elements when an + atom exists. Follow the design rules in `CLAUDE.md`. +3. All persistence goes through the VFS. All file paths through `@lumen/vfs` + helpers. +4. Export `default` an `AppDefinition` from `index.ts`; register in + `packages/apps/src/registry.ts`. +5. Logic (parsers, evaluators, reducers) lives in plain `.ts` files with + tests. + +Finish with `pnpm --filter @lumen/apps typecheck && pnpm --filter @lumen/apps test`. diff --git a/.claude/agents/kernel-engineer.md b/.claude/agents/kernel-engineer.md new file mode 100644 index 00000000..4435bd0e --- /dev/null +++ b/.claude/agents/kernel-engineer.md @@ -0,0 +1,20 @@ +--- +name: kernel-engineer +description: Owns packages/kernel, packages/vfs and packages/platform — process table, window manager, app registry, settings, users/lock, VFS adapters, host bridge. Use for state, persistence, and cross-app contracts. +tools: Read, Edit, Write, Glob, Grep, Bash +--- + +You maintain the core of Lumen OS. Read `ARCHITECTURE.md` first. + +- Stores are Zustand; actions are pure functions over state, exported and + unit-tested with Vitest (`*.test.ts` next to the code). +- No DOM access in the kernel. Anything host-specific goes through + `packages/platform`. +- Every public type lives in a `types.ts` and is re-exported from the package + barrel. Changing a contract means updating every consumer in the same change. +- Persistence goes through the VFS (`/System/*.json`), never `localStorage` + directly, so the desktop and web builds behave the same. +- Path handling: always use the helpers in `packages/vfs/src/path.ts`; + never string-concatenate paths. + +Verify with `pnpm --filter @lumen/kernel test` and `pnpm typecheck`. diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md new file mode 100644 index 00000000..45ea18c9 --- /dev/null +++ b/.claude/agents/reviewer.md @@ -0,0 +1,19 @@ +--- +name: reviewer +description: Adversarial reviewer. Use before a pull request to find correctness bugs, contract mismatches between packages, accessibility gaps, and design slop. Reports findings with file:line; does not rewrite code unless asked. +tools: Read, Glob, Grep, Bash +--- + +Review the diff or the package you are pointed at. Try to refute that it +works. For each finding give `file:line`, what breaks, and how to reproduce. + +Check in this order: +1. Contracts: does every consumer match the type it imports? Run `pnpm typecheck`. +2. Behaviour: run the tests; write a failing test for any bug you claim. +3. Persistence: does state survive reload? Are VFS writes awaited? +4. Resilience: what happens at 320×480 and 5120×2880? With reduced motion? + With keyboard only? +5. Design: run `pnpm deslop`; read the UI against `CLAUDE.md` → Design rules. +6. Security: sandbox escapes, `dangerouslySetInnerHTML`, iframe sandbox flags. + +Rank findings by severity. No findings without evidence. diff --git a/.claude/agents/rust-engineer.md b/.claude/agents/rust-engineer.md new file mode 100644 index 00000000..56881dc4 --- /dev/null +++ b/.claude/agents/rust-engineer.md @@ -0,0 +1,19 @@ +--- +name: rust-engineer +description: Owns crates/lumen-kernel and apps/desktop/src-tauri — the sandboxed file system, system information, configuration, and Tauri commands. Use for anything native or Windows-specific. +tools: Read, Edit, Write, Glob, Grep, Bash +--- + +You maintain the native side of Lumen OS. + +- The sandbox is the security boundary. Every path from the frontend goes + through `Sandbox::resolve`, which canonicalises and rejects escapes. Add a + test for every new operation. +- Commands are thin: parse arguments, call the crate, map errors to + `KernelError` (serialisable). No business logic in `main.rs`. +- `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, + `cargo test --workspace` must all pass. No `unwrap`/`expect` outside tests. +- Windows is the release target. Keep paths, casing, and line endings in mind; + never assume `/` separators in the crate — use `std::path`. +- Tauri capabilities live in `apps/desktop/src-tauri/capabilities/`. Grant the + minimum. diff --git a/.claude/agents/ui-designer.md b/.claude/agents/ui-designer.md new file mode 100644 index 00000000..6024c1ee --- /dev/null +++ b/.claude/agents/ui-designer.md @@ -0,0 +1,26 @@ +--- +name: ui-designer +description: Builds and reviews interface components in packages/ui and the shell. Use for any visual work — atoms, molecules, organisms, layout, motion, cursor, theming. Enforces the design rules in CLAUDE.md and the kill-ai-slop taxonomy. +tools: Read, Edit, Write, Glob, Grep, Bash +--- + +You are the interface designer for Lumen OS. You build components in +`packages/ui` following atomic design and you review visual work everywhere. + +Before writing a component: +1. Read `packages/tokens/src/theme.css` and `CLAUDE.md` → Design rules. +2. Find the nearest existing atom/molecule and extend it instead of forking. + +Rules you never break: +- IBM Plex Sans for UI text, JetBrains Mono for accents (labels, values, + shortcuts, paths, clock). Tabular numerals. +- One accent colour. Neutral surfaces. Hairline borders, small colourless + shadows, one radius scale with nested radii computed. +- Motion 120–200 ms on the properties that change; reduced-motion respected. +- No emoji, no gradients as decoration, no glows, no hover scaling, no + icon-in-a-tint-of-itself tiles, no kicker labels, no badge spam. +- Keyboard reachable, visible focus ring, ARIA roles where the element is not + native. + +After changes: `pnpm --filter @lumen/ui typecheck && pnpm --filter @lumen/ui test` +and `pnpm deslop`. Report remaining scan hits with a reason for each. diff --git a/.claude/hooks/format-changed.mjs b/.claude/hooks/format-changed.mjs new file mode 100644 index 00000000..ec312007 --- /dev/null +++ b/.claude/hooks/format-changed.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node +// PostToolUse hook: format the file that was just written with Biome (JS/TS/JSON/CSS) +// or rustfmt (Rust). Reads the tool payload from stdin; never fails the tool call. +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; + +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + input += chunk; +}); +process.stdin.on('end', () => { + try { + const payload = JSON.parse(input || '{}'); + const file = payload?.tool_input?.file_path; + if (!file || !existsSync(file)) return; + if (/\.(ts|tsx|js|jsx|mjs|cjs|json|jsonc|css)$/.test(file)) { + execFileSync('pnpm', ['exec', 'biome', 'format', '--write', file], { stdio: 'ignore' }); + } else if (file.endsWith('.rs')) { + execFileSync('rustfmt', ['--edition', '2021', file], { stdio: 'ignore' }); + } + } catch { + // formatting is best-effort + } +}); diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..3a201806 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["run", "dev"], + "port": 3000 + } + ] +} diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..561694d5 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "allow": [ + "Bash(pnpm *)", + "Bash(pnpm:*)", + "Bash(npx *)", + "Bash(node *)", + "Bash(cargo *)", + "Bash(rustfmt *)", + "Bash(turbo *)", + "Bash(biome *)", + "Bash(git status*)", + "Bash(git diff*)", + "Bash(git log*)", + "Bash(git add *)", + "Bash(git commit *)", + "Bash(git switch *)", + "Bash(git checkout *)", + "Bash(git branch*)", + "Bash(git fetch*)", + "Bash(git push *)", + "Bash(ls *)", + "Bash(cat *)", + "Bash(head *)", + "Bash(tail *)", + "Bash(grep *)", + "Bash(rg *)", + "Bash(find *)", + "Bash(wc *)", + "Bash(mkdir *)", + "Bash(cp *)", + "Bash(mv *)", + "Read", + "Edit", + "Write", + "Glob", + "Grep", + "WebFetch(domain:v2.tauri.app)", + "WebFetch(domain:react.dev)", + "WebFetch(domain:tailwindcss.com)", + "WebFetch(domain:vite.dev)", + "WebFetch(domain:threejs.org)", + "WebFetch(domain:docs.pmnd.rs)", + "WebFetch(domain:vercel.com)", + "WebFetch(domain:turbo.build)" + ], + "deny": [ + "Bash(git push --force*)", + "Bash(git push -f*)", + "Bash(rm -rf /*)", + "Read(./.env)", + "Read(./.env.*)" + ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "node .claude/hooks/format-changed.mjs" + } + ] + } + ] + } +} diff --git a/.claude/skills/kill-ai-slop/LICENSE b/.claude/skills/kill-ai-slop/LICENSE new file mode 100644 index 00000000..cf0de31d --- /dev/null +++ b/.claude/skills/kill-ai-slop/LICENSE @@ -0,0 +1,202 @@ + 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/.claude/skills/kill-ai-slop/README.md b/.claude/skills/kill-ai-slop/README.md new file mode 100644 index 00000000..a0a241d3 --- /dev/null +++ b/.claude/skills/kill-ai-slop/README.md @@ -0,0 +1,113 @@ +# kill-ai-slop — an Agent Skill + +Scan a web project for **AI slop**, the generic machine-default visual and copy +tics of vibe-coded products, and strip it out. This skill turns the catalogue at +**[killaislop.com](https://killaislop.com)** into action: it detects 35 tells +from their code-level signals, explains why each reads as machine-made, and +proposes or applies the clean fix. + +It covers indigo→violet gradients, gradient-clip headlines, the default semantic +palette, one-hue status boxes, atmospheric background gradients, serif-italic +emphasis, decorative strikes/highlights, highlighted keywords, AI copywriting +voice, emoji spam, glowing status dots, colored-left-border callouts, pastel +icon tiles, glassmorphism and over-rounding, oversized drop shadows, corners +that don't nest, borders that die at rounded corners, badge & pill spam, +AI-drawn SVG icons, icon-in-a-tint-of-itself +tiles, kickers over every heading, full-sentence display headlines, flat type +hierarchies, springy hover effects, wobbling off-centre spinners, all-caps +stat-card grids, invented stat +rows, 01/02/03 section markers, cards nested in cards, monotone one-gap +spacing, the default Inter/Space Grotesk look, the "tasteful terminal" +default, and the editorial-serif dashboard costume. It works across +HTML/CSS, React/Vue/Svelte/Astro, Tailwind, PHP/Twig +templates (WordPress themes and plugins included), and Markdown copy. + +## Install — just ask your agent + +This is an [Agent Skill](https://docs.claude.com/en/docs/agents-and-tools/agent-skills/overview). +You don't need to configure anything by hand. Tell your coding agent, in plain +language, to fetch it from this repo and install it wherever that agent keeps +its skills. Paste something like this: + +``` +Install the kill-ai-slop skill from +https://github.com/yetone/kill-ai-slop/tree/main/skill + +Copy everything in that directory into a kill-ai-slop/ folder +inside your agent's skills directory, then confirm it's registered. +``` + +You don't have to name the files or spell out the path. Your agent knows where +its own skills live and copies the whole directory in as it is. (Claude Code, +for example, reads `.claude/skills/` in the project, or `~/.claude/skills/` to +have it everywhere; other agents have their own location.) The agent reads +`SKILL.md`, installs the skill, and registers it. + +## Install — manually + +If you'd rather do it yourself, clone the repo and copy the whole `skill` +directory into wherever your agent reads skills from, under the name +`kill-ai-slop`: + +```bash +git clone https://github.com/yetone/kill-ai-slop + +# copy the entire directory; for example, with Claude Code: +cp -r kill-ai-slop/skill ~/your-project/.claude/skills/kill-ai-slop # this project only +cp -r kill-ai-slop/skill ~/.claude/skills/kill-ai-slop # every project +``` + +Copy the directory whole; the skill is self-contained, with no packages and no +build step. Swap the destination for your own agent's skills folder if it isn't +Claude Code. + +## Use it + +Once installed, ask your agent: + +> Kill the AI slop in this project. + +(or "de-slop this", "remove the AI look", "make this not look AI-generated"). It +will **scan** the code for each tell, **triage** slop vs. a real chosen design, +**report** a grouped summary before touching anything, and **fix** only the +groups you approve, with the smallest diff that removes the tell without +changing intent. + +You can also run the scanner on its own; it only reads, never edits: + +```bash +node scripts/scan.mjs path/to/src # human-readable report +node scripts/scan.mjs path/to/src --json # machine-readable +``` + +Every hit is a lead to confirm by reading the code, not a verdict. A gradient, a +serif, or an emoji can be a real, defended choice. The skill flags defaults and +keeps anything you clearly decided. + +For hits you've confirmed as intentional, and for narrower scans, the scanner +supports: + +```bash +node scripts/scan.mjs src --only=01,06 # scan just these tells +node scripts/scan.mjs src --skip=19,26 # everything but these +node scripts/scan.mjs src --exclude=legacy # drop paths (substring match) +node scripts/scan.mjs src --rules=my.mjs # load extra project/language tells +``` + +and comment directives in source: `deslop-ignore` (this line), +`deslop-ignore-next-line`, and `deslop-ignore-file` — each optionally scoped to +tell ids, e.g. `/* deslop-ignore-next-line 06 */`. Prefer the id-scoped forms so +new tells still surface. `scripts/rules.ru.mjs` ships as a working example of a +`--rules` module (Russian AI-copywriting tells) and the template for your own +language- or stack-specific rules. + +## What's inside + +| File | | +|---|---| +| `SKILL.md` | The skill definition, workflow, and guardrails. | +| `references/taxonomy.md` | The 35 tells: what each is, why it's slop, the fix. | +| `references/detection.md` | The code patterns per tell, and their false positives. | +| `references/fixes.md` | Before→after remediation patterns. | +| `scripts/scan.mjs` | The dependency-free scanner. | +| `scripts/rules.ru.mjs` | Example `--rules` module: Russian copy tells. | diff --git a/.claude/skills/kill-ai-slop/SKILL.md b/.claude/skills/kill-ai-slop/SKILL.md new file mode 100644 index 00000000..13f0e474 --- /dev/null +++ b/.claude/skills/kill-ai-slop/SKILL.md @@ -0,0 +1,121 @@ +--- +name: kill-ai-slop +description: >- + Find and remove AI slop — the generic, machine-default visual and copy tics of + vibe-coded products — from a web project. Use when the user asks to "kill AI + slop", "de-slop", "remove the AI look", "make this not look AI-generated", or + clean up a landing page / UI / docs that feels templated. Detects and fixes + the catalogue of tells: indigo→violet gradients, gradient-clip headlines, the + default semantic palette, one-hue status boxes, atmospheric gradients, + serif-italic emphasis, highlighted keywords, AI copywriting voice ("not just + X — it's Y"), emoji everywhere, glowing status dots, wobbling spinners, + colored-left-border + callouts, pastel icon tiles, glassmorphism, over-rounding, oversized shadows, + borders that die at corners, badge & pill spam, AI-drawn SVG icons, kickers + over every heading, flat type + hierarchies, invented stat rows, 01/02/03 section + markers, cards nested in cards, the default Inter/Space Grotesk look, and + more. Works on HTML/CSS, React/Vue/Svelte/Astro, Tailwind, PHP, and Markdown + copy. +--- + +# Kill AI Slop + +AI slop is **ugly** in a specific way: it piles on every possible style and +detail without settling on a focus. A gradient, a glow, a mascot, emoji, a wall +of glowing cards, every default switched on at once, until every product looks +like the same garish template. It reads as "designed" in a thumbnail and falls +apart the moment anyone looks. Your job is to strip it back to something a +person would actually choose. + +The principles, held on every fix you make: + +1. **Decide before you decorate.** Every visual choice must be explainable. +2. **One accent, one voice.** +3. **Hierarchy from scale and space.** Coloring words or swapping fonts is a shortcut. +4. **Subtract first.** The first move toward not-ugly is removing things. +5. **Specific beats punchy** in copy. +6. **Decoration must mean something** — icons, badges, callouts are signals. + +## Workflow + +Follow these steps in order. Do not mass-edit before the user has seen the report. + +### 1. Scope +Confirm what to scan. Default to the app/site source (skip `node_modules`, +`dist`, `build`, `.git`, `vendor`, lockfiles, minified files). Ask if the +project mixes several apps. + +### 2. Scan +Run the bundled scanner, which greps the codebase for the code-level signals of +each tell and prints grouped `file:line` hits: + +``` +node scripts/scan.mjs # human-readable report +node scripts/scan.mjs --json # machine-readable, for triage +``` + +It is pure Node (no dependencies) and never edits files. Use its output as a +starting map, not gospel — confirm each hit by reading the code. + +To narrow a scan: `--only=01,06` / `--skip=19` filter by tell id, and +`--exclude=legacy` drops paths (substring match on the project-relative path). +`--rules=extra.mjs` loads additional project- or language-specific tells +(`scripts/rules.ru.mjs` is a shipped Russian-copy example and the template for +your own). Hits the user has confirmed as intentional can be pinned in source +with `deslop-ignore`, `deslop-ignore-next-line 06`, or `deslop-ignore-file` +comments — prefer the id-scoped forms so new tells still surface. + +### 3. Triage +For every hit, open the file and decide **slop vs. intentional**. This is the +step that separates this skill from a lint rule. A gradient, a serif, or an +emoji can be a real, defended choice. Keep anything the user clearly chose +(brand tokens, a logo, a deliberate illustration). Flag only defaults. + +Read `references/taxonomy.md` for what each tell is and why it reads as +machine-made, and `references/detection.md` for the exact patterns and their +common false positives. + +### 4. Report +Before changing anything, give the user a grouped summary: each tell, the +`file:line` hits you confirmed, one sentence on why, and the proposed fix. +Mirror the format: + +``` +slop src/Hero.tsx:12 indigo→violet gradient → one solid accent +slop src/Hero.tsx:31 gradient-clip headline → solid ink, scale up +slop src/Note.tsx:8 border-l-4 callout ×3 → 1 aside, rest is body +slop copy.md:1 "not just X — it's Y" → say the specific thing +→ 4 groups, 11 hits. +``` + +Then ask which groups to apply, or whether to proceed on all. + +### 5. Fix +Apply the minimal change that removes the tell while preserving intent and +function. Use `references/fixes.md` for the before→after pattern per tell. + +- Prefer editing shared tokens/components over touching every call site. +- Never invent new brand colors; if a palette must change, propose neutrals + + the project's existing accent and let the user confirm. +- Keep copy meaning; make it specific, don't just delete it. +- Re-run the scanner after fixing to confirm the count dropped, and note any + hits you intentionally left (with the reason). + +## Guardrails + +- **Respect authorship.** Treat unfamiliar files and deliberate flourishes as + someone's choice. When unsure whether something is slop, ask — don't strip it. +- **Small, reviewable diffs.** Never reformat unrelated code. Never run + `git add -A`; stage explicit files only, and leave others' work-in-progress + alone. +- **No new dependencies** to do this work. +- **Verify visually when possible.** If a dev server exists, look at the before + and after; a passing scan is not the same as a better page. + +## References + +- `references/taxonomy.md` — the 35 tells: what each is, why it's slop, the fix. +- `references/detection.md` — concrete ripgrep/regex patterns + false positives. +- `references/fixes.md` — before→after remediation patterns. +- `scripts/scan.mjs` — the dependency-free scanner. diff --git a/.claude/skills/kill-ai-slop/references/detection.md b/.claude/skills/kill-ai-slop/references/detection.md new file mode 100644 index 00000000..3e57f260 --- /dev/null +++ b/.claude/skills/kill-ai-slop/references/detection.md @@ -0,0 +1,411 @@ +# Detection patterns + +Concrete signals for each tell. `scripts/scan.mjs` encodes these; this file is +the human reference and the place to widen patterns for a specific stack. + +**Scan scope.** Frontend source only: `.html .css .scss .tsx .jsx .ts .js .vue +.svelte .astro .md .mdx .php .twig` plus `tailwind.config.*`. Skip +`node_modules dist build .git out .next .astro coverage vendor` and anything +`*.min.*` or a lockfile. + +**Every match is a lead, not a verdict.** Open the file and confirm. The false +positives noted below are the usual ways a pattern lies. + +**Suppression.** Once the user has defended a hit, pin the decision so re-scans +stay quiet: `--only=` / `--skip=` filter by tell id, `--exclude=` drops paths, +and comment directives suppress in source — `deslop-ignore` (this line), +`deslop-ignore-next-line`, `deslop-ignore-file`, each optionally followed by +tell ids (`/* deslop-ignore-next-line 06 */`). Prefer id-scoped directives so +other tells still surface on those lines. + +**Extension.** `--rules=extra.mjs` loads additional tells (same shape as the +core ones; string patterns compile case-insensitive). Use it for +language-specific copy rules — English patterns like tell 14's don't fire on +non-English slop. `scripts/rules.ru.mjs` is a shipped Russian example. Remember +JavaScript's `\b` is ASCII-only: it never matches next to Cyrillic and most +non-Latin scripts, so write plain substrings instead. + +--- + +### 01 Indigo→violet gradient +``` +rg -n -i 'from-(indigo|violet|purple|fuchsia)-[0-9]+ ?.*to-(purple|violet|fuchsia|pink)-[0-9]+' +rg -n -i '(linear-gradient|bg-gradient)[^;]*(#6366f1|#8b5cf6|#a855f7|#7c3aed)' +rg -n -i 'shadow-(purple|violet|indigo)-[0-9]+/[0-9]+' +``` +*False positives:* a brand that genuinely is purple (check the logo / design +tokens); a one-off illustration. Slop is the *combination* — purple gradient + +glow + pill button + "AI-powered" eyebrow. + +### 02 Gradient headline text +``` +rg -n 'bg-clip-text.*text-transparent|text-transparent.*bg-clip-text' +rg -n '(-webkit-)?background-clip:\s*text' +rg -n -i '-webkit-text-fill-color:\s*transparent' +``` +*False positives:* a logo wordmark; a single deliberate hero treatment. Slop is +gradient text used as the default heading style. + +### 03 Warm "cozy" palette +``` +rg -n -i '\b(amber|orange|stone)-(50|100|200|300)\b' +rg -n -i 'bg-\[#(fdf6ec|fef3e2|faf3e8|fff7ed|fdf4e3)\]' +rg -n -i 'text-amber-[0-9]+|border-amber-[0-9]+' +rg -n -i 'text-(gray|slate|zinc|neutral)-(400|500)[^"]{0,60}bg-(amber|stone|orange|rose|blue|indigo|green)-' +``` +*False positives:* a food/coffee/craft brand where warm is the identity. Slop is +warm-as-default with no brand reason. The last pattern is the sibling tell: +default gray text dropped onto a tinted surface — the gray was never toned to +the background; use a darker shade of the surface hue instead. + +### 04 Default semantic palette +``` +rg -n -i 'bg-(blue|indigo)-50|bg-amber-50|bg-green-50|bg-emerald-50|bg-red-50' +rg -n -i 'text-(blue|indigo)-(600|700).*text-(green|emerald)-(600|700)' # multiple stock hues near each other +rg -n -i 'info.*blue|warning.*amber|success.*green|error.*red' +``` +*Confirm:* three or four of the stock `-50 / -600` semantic pairs used together +(info=blue, tip=amber, success=green, error=red), unrelated to the brand. A +single, deliberate status colour is fine. + +### 05 One-hue status box +``` +rg -n -i 'border-(red|amber|yellow|green|blue)-[0-9]+[^"]*text-\1-[0-9]+' +rg -n -i 'bg-(red|amber|yellow|green)-[0-9]+/(5|10|15|20)' +rg -n -i '(error|warning|success)[^\n]{0,40}(red|amber|yellow|green)-[0-9]+' +``` +*Confirm:* one box where border, text, and a `/10` background are all the same +hue — the whole alert is one colour at three opacities. A single muted accent on +a neutral surface is fine. + +### 06 Gradients as atmosphere +``` +rg -n -i 'radial-gradient|bg-\[radial-gradient' +rg -n -i 'linear-gradient[^;]*(to bottom|180deg|to top)' # top-lighter surface fill +rg -n -i 'bg-gradient-to-(b|t)\b[\s\S]{0,40}from-' +rg -n -i 'repeating-(linear|radial)-gradient' +rg -n -i 'bg-gradient-to-(br|tr|bl|tl)\b[\s\S]{0,40}from-(emerald|green|teal|cyan|purple|violet|fuchsia)-\d+/(5|10|15|20|25)' # per-card accent-hue wash +rg -n -i 'box-shadow:[^;]*(#(6366f1|8b5cf6|a855f7|22d3ee|06b6d4)|rgba?\(139|rgba?\(168)' # colored glow accents +``` +*Confirm:* a page-wide radial glow behind everything, or card surfaces filled +with a top-to-bottom gradient where a flat colour would do; bento cards each +washed in a tint of their own accent (green card → green gradient, purple → +purple); repeating-gradient stripes as surface decoration; colored box-shadow +glows as dark-mode accents. A gradient that points at something specific is a +choice, not slop. + +### 07 Serif-italic on one word +Hard to grep purely; look for a serif font family or `/italic` applied +*inside* an otherwise-sans heading. +``` +rg -n -i 'font-serif|font-family:\s*(georgia|"?playfair|"?lora|"?cormorant)' +rg -n '|italic' -g '*.{tsx,jsx,vue,svelte,astro,html}' +``` +*Confirm:* the italic/serif span sits within an `

/

` whose base font is +sans. + +### 08 Serif where sans belongs +``` +rg -n -i 'font-family:\s*[^;]*(playfair|cormorant|lora|"?dm serif|"?libre baskerville)' +rg -n -i "fontFamily.*(Playfair|Cormorant|Lora)" +rg -n -i 'font-serif[^"]{0,60}(italic|text-[4-9]xl)|(italic|text-[4-9]xl)[^"]{0,60}font-serif' +``` +*False positives:* an editorial/publishing product that wants serif. Slop is +display serif as UI/body on a tool or dashboard — or the oversized italic-serif +hero headline, now the universal AI-startup landing page uniform. Set it roman +or use a non-serif display face; a genuinely editorial register may keep it. + +### 09 Decorative strikes & highlights +``` +rg -n -i 'line-through' -g '*.{css,scss,tsx,jsx,vue,svelte,astro,html}' +rg -n '<(mark|s|u|del|strike)[\s>]' -g '*.{md,mdx,html,tsx,jsx,vue,svelte,astro}' +rg -n -i 'text-decoration:\s*(line-through|underline)' +``` +*Confirm:* a strike over text that isn't being deleted, an underline that isn't +a link, a highlighter swipe under a heading — decoration, not annotation. Real +edits, links, and annotations are fine. + +### 10 The kicker above every heading +``` +rg -n -i 'uppercase[\s\S]{0,40}tracking-(wide|wider|widest)|tracking-(wide|wider|widest)[\s\S]{0,40}uppercase' +rg -n -i '\b(eyebrow|kicker|overline)\b' -g '*.{css,scss,tsx,jsx,vue,svelte,astro,html}' +rg -n -i 'text-transform:\s*uppercase[\s\S]{0,80}letter-spacing:\s*0?\.[0-9]+em' +``` +*Confirm:* the same tracked-caps micro-label above the hero *and* every section +heading, saying what the heading already says (FEATURES over features). A +kicker that adds a real dimension — a category, a date, a number in a genuine +sequence — is an editorial device doing its job. + +### 11 Full-sentence display headline +``` +rg -n 'text-(5|6|7|8|9)xl' -g '*.{tsx,jsx,vue,svelte,astro,html}' +rg -n -i 'tracking-tighter?\b[\s\S]{0,40}font-(extrabold|black)|font-(extrabold|black)[\s\S]{0,40}tracking-tighter?\b' +rg -n -i 'font-size:\s*(clamp\([^)]*[4-9](rem|\.[0-9]+rem)|[5-9][0-9]px|[4-9]rem)' +``` +*Confirm:* a full sentence (10+ words) at display size, usually extrabold with +crushed tracking, wrapping to 3+ lines. Two or three words at display size is +what display sizes are for. Crushed tracking on its own is the same tell: +`letter-spacing` past the point where characters keep their shapes +(`rg -n -i 'letter-spacing:\s*-0?\.0[5-9]'`). + +### 12 Flat type hierarchy +``` +rg -n ']*text-(sm|base|lg)\b' +rg -n -i 'font-size:\s*1[4-8]px' -g '*.css' # then count distinct sizes +``` +Mostly a visual judgment: open the page and count the sizes actually in use. +*Confirm:* headings barely bigger than body (steps under ~1.25×), hierarchy +carried by gray shades instead of size. A deliberately quiet, single-size +editorial layout with strong spacing is a choice; a dashboard where the page +title and a table cell match is not. + +### 13 Highlighted keywords +``` +rg -n ']*(width|height)="(9[6-9]|[1-9][0-9]{2})"' +``` +*Confirm:* one icon-in-a-tile per feature, in a grid, icons unrelated to +content — or the same reflex at another size, a giant decorative line icon +parked in a card as filler. + +### 19 Max radius + glassmorphism +``` +rg -n 'rounded-full' -g '*.{tsx,jsx,vue,svelte,astro,html}' +rg -n -i 'backdrop-blur|backdrop-filter:\s*blur|bg-white/(5|10|20|30)|bg-black/(5|10|20|30)' +rg -n -i 'border-radius:\s*(9999px|50%|2rem|24px)' +``` +*Confirm:* `rounded-full` on cards/inputs (not just avatars/pills); blur used as +the default surface; radius values that don't agree with each other. + +### 20 Oversized drop shadow +``` +rg -n -i 'box-shadow:[^;{}]*\b([6-9][0-9]|[0-9]{3,})px' # a 60px+ blur/spread +rg -n -i 'shadow-\[[^\]]*\b([6-9][0-9]|[0-9]{3,})px' # tailwind arbitrary shadow +rg -n -i 'filter:[^;{}]*drop-shadow\([^)]*\b([6-9][0-9]|[0-9]{3,})px' +rg -n -i '\bborder\b[\s\S]{0,40}shadow-(xl|2xl)|shadow-(xl|2xl)[\s\S]{0,40}\bborder\b' +``` +*Confirm:* the shadow's render range is far bigger than the element casting it — +a small card/icon under a huge, faint, barely-offset blur (a fog, not a drop). +A genuinely large surface (a modal, a full hero) with a proportionate shadow is +fine; so is one small, tight elevation shadow. The last pattern is the "ghost +card" variant: a hairline border *and* a wide diffuse shadow on the same card — +two separators doing one job; commit to an edge or an elevation. + +### 21 Corners that don't nest +Hard to grep with certainty; flag the same radius token on nested containers. +``` +rg -n -i 'rounded-(xl|2xl|3xl)' -g '*.{tsx,jsx,vue,svelte,astro,html}' # then check for nesting +rg -n -i 'border-radius:\s*(1rem|1\.5rem|24px|32px)' +``` +*Confirm:* an outer box and an inner box carrying the *same* big radius, so the +corners don't sit concentric. Inner radius should be outer minus the padding; a +single flat radius scale that already nests correctly is fine. + +### 22 Border that dies at the corner +The break is visual (a stroke on the straight edges, none along the arcs); +grep for the code that produces it — a radius and a border on different boxes. +``` +rg -n -i 'rounded-(lg|xl|2xl|3xl)[^"]{0,60}overflow-(hidden|clip)|overflow-(hidden|clip)[^"]{0,60}rounded-(lg|xl|2xl|3xl)' # clipping wrapper: check the child for a border +rg -n -i 'clip-path:\s*inset\([^)]*round' +rg -n -i 'border-[trbl]\b[^"]{0,60}rounded-(lg|xl|2xl|3xl)|rounded-(lg|xl|2xl|3xl)[^"]{0,60}border-[trbl]\b' +``` +*Confirm:* a `rounded overflow-hidden` wrapper whose child carries the 1px +border/dividers — the square ring is erased at every corner by the clip; a +`clip-path: inset(… round …)` cutting a border off; or a single-side border +stopping mid-arc on a rounded box. A border set on the rounded element itself +wraps the arc and is fine; so are straight dividers between square-cornered +regions. + +### 23 Badge & pill spam +``` +rg -n -i 'rounded-full .*(bg-(indigo|purple|green|amber|pink)-(50|100|200))' +rg -n -i '>(\s*[✨🔥🎉🚀]\s*)?(new|beta|hot|popular|pro|coming soon)\s*<' +``` +*Confirm:* several decorative pills in chrome. A real version tag is fine. + +### 24 AI-drawn SVG icons +Grep finds inline SVG; the judgment is visual. +``` +rg -n ']*r="[0-9]' # dot eyes / blob body built from primitives +rg -n -i '(mascot|blob|logo)\.svg' +``` +*Confirm:* an inline `` that's a blob-with-dot-eyes or a cartoon creature +built from primitive shapes, shipped as the product's mark. A drawn icon set or +a real designed logo is fine. + +### 25 Icon in a tint of itself +``` +rg -n -i 'bg-(indigo|blue|green|amber|red|purple|pink)-[0-9]+/(5|10|15|20)[\s\S]{0,60}text-\1-' +rg -n -i 'text-(indigo|blue|green|amber|red|purple|pink)-[0-9]+[\s\S]{0,60}bg-\1-[0-9]+/(5|10|15|20)' +rg -n -i 'rounded-(md|lg|xl)\s+bg-(indigo|blue|green|amber|red)-[0-9]+/(5|10|15|20)' +``` +*Confirm:* an icon tile whose translucent background is the same hue as the icon +inside it — every glyph wrapped in a soft colored square. A deliberate opaque +button surface is fine. + +### 26 The springy hover +``` +rg -n -i 'hover:(scale-1[01][0-9]|-translate-y-)' +rg -n '\btransition-all\b' +rg -n -i 'cubic-bezier\([^)]*,\s*1\.[2-9][0-9]*' # overshoot easing = bounce +rg -n -i 'animate-bounce|transition:[^;]*\b(width|height|margin|padding)\b' +``` +*Confirm:* scale/lift/bounce on hover across cards, buttons, and images, and +`transition-all` as the default. A drawer that springs as it physically slides +in is motion doing a job; a card that jumps when the cursor grazes it is not. +Also flag animating layout properties (width/height/margin/padding) — jank on +top of the decoration. + +### 27 The wobbling spinner +``` +rg -n 'translate\(-50%, ?-50%\)' -g '*.{css,tsx,jsx,vue,svelte,astro,html}' +rg -n '@keyframes +[a-zA-Z-]*(spin|rotate|load)' +rg -n -i 'animate-spin' +rg -n 'transform-origin: *[0-9]' +``` +*Confirm:* run the page and watch one full revolution — the arc's centre must +not trace a circle. Code tells: a spin keyframe that sets `transform: +rotate(…)` on an element centered with `transform: translate(-50%, -50%)` (the +keyframe replaces the whole property, so the element snaps off-centre and +orbits); a `transform-origin` that isn't the element's centre; `animate-spin` +on an emoji or on an SVG whose artwork isn't centred in its viewBox. A +centered element whose keyframe re-states the translate (`translate(-50%, +-50%) rotate(360deg)`) is fine. + +### 28 The all-caps card grid +``` +rg -n -i 'grid-cols-3' +rg -n -i 'uppercase[\s\S]{0,30}(text-xs|tracking-wide|tracking-wider)|text-transform:\s*uppercase' +rg -n -i 'everything you need|why (you.?ll love|choose|teams)' +``` +*Confirm:* an ALL-CAPS micro-label + number/icon repeated across interchangeable +cards — a feature grid or a stat-card grid — points unrelated. + +### 29 The invented stat row +``` +rg -n -i '\b[0-9]+[km]\+' +rg -n '99\.9%|24/7' +rg -n -i '(10k|50k|99\.9|24/7)[\s\S]{0,60}(developers|users|teams|uptime|support|countries)' +``` +*Confirm:* three big round numbers in a row — a `Nk+`, a `99.9%`, a `24/7` — +with tiny uppercase labels, in the hero or above the fold. A real, odd, +sourced figure ("1,847 CI runs yesterday") is the fix, not a hit. + +### 30 The 01 / 02 / 03 section markers +``` +rg -n '["'\''>`]0[1-9]["'\''<`]' -g '*.{tsx,jsx,vue,svelte,astro,html}' +rg -n -i 'text-(7|8|9)xl[\s\S]{0,50}(text-(gray|slate|zinc|neutral)-(100|200)|opacity-(5|10|20))' +rg -n -i 'step[- ](one|two|three)' +``` +*Confirm:* giant faint ordinals stapled to *unordered* marketing sections. A +genuinely ordered sequence — install steps, a changelog, an indexed catalogue — +has earned its numbers. + +### 31 Cards inside cards +Grep gives leads; the judgment is in the rendered DOM. +``` +rg -n '<(Card|Panel|Box)[^>]*>\s*<\1' -g '*.{tsx,jsx,vue,svelte}' +rg -n -i 'rounded[^"]*border[^"]*"[\s\S]{0,120}rounded[^"]*border' -g '*.{tsx,jsx,vue,svelte,astro,html}' +``` +*Confirm:* three or more nested surfaces each with its own border/radius/shadow +and shrinking padding. A child that is genuinely a separate object (a preview, +an embed) may keep its own surface. + +### 32 One gap everywhere +``` +rg -n '(space-y-4|gap-4)[^"'\''`]{0,80}(space-y-4|gap-4)' # the token twice on one line +rg -n -c 'space-y-4|gap-4' # per-file counts; high = lead +``` +Mostly a visual judgment: the grep only finds the token, the tell is the +*absence of any other value*. Count the distinct spacing values in a component; +one value for both within-group and between-group distances is the tell. +*Confirm:* a heading equidistant from its own body and from the previous +section. A deliberate uniform grid (a photo wall, a calendar) is a choice. + +### 33 Inter everywhere (evolved) +``` +rg -n -i 'fonts\.googleapis\.com/css2\?family=(Inter|Space\+Grotesk|Manrope|Plus\+Jakarta)' +rg -n -i 'font-family:\s*[^;]*("?Inter"?|"?Space Grotesk"?|"?Manrope"?|"?Plus Jakarta Sans"?|"?Geist"?)' +rg -n 'from ["'\'']next/font/google' -g '*.{ts,tsx,js,jsx}' +``` +*Confirm:* judgment tell — the faces themselves are good. Slop is the stock +pairing (Space Grotesk display + Inter body) or Inter-by-default with no sign +anyone compared alternatives. A team that tried others and landed on Inter made +a choice; check for any evidence of one (a comment, a brand doc, a deliberate +fallback stack). + +### 34 The tasteful terminal (evolved) +``` +rg -n -i 'font-mono' -g '*.{tsx,jsx,vue,svelte,astro,html}' +rg -n -i 'font-family:\s*[^;]*(mono|jetbrains|fira code|ibm plex mono|geist mono)' +rg -n '[╔╗╚╝║═▓▒░]|\$ [a-z]' -g '*.{tsx,jsx,vue,svelte,astro,html,md}' +``` +*Confirm:* monospace on non-code UI chrome; ASCII banners as decoration; +near-black with a single warm accent. This one needs judgment most — it's a +current default, so weigh whether the terminal metaphor actually serves the +product. + +### 35 The editorial dashboard (evolved) +``` +rg -n -i '>\s*Good (morning|afternoon|evening),' -g '*.{tsx,jsx,vue,svelte,astro,html}' +rg -n -i 'font-serif' -g '*.{tsx,jsx,vue,svelte,astro,html}' +rg -n -i 'font-family:\s*[^;]*(fraunces|canela|tiempos|didot|freight|reckless|newsreader)' +rg -n -i 'oldstyle-nums|font-variant-numeric:\s*oldstyle' +``` +*Confirm:* the serif is on app chrome (a dashboard h1, stat-card numerals), +not on genuinely editorial content; the greeting-as-headline sits on an +operational surface; the cream + tracked-caps kicker + deep accent kit came +as a set. A docs site or essay page in a text serif is not this tell. diff --git a/.claude/skills/kill-ai-slop/references/fixes.md b/.claude/skills/kill-ai-slop/references/fixes.md new file mode 100644 index 00000000..6324be5d --- /dev/null +++ b/.claude/skills/kill-ai-slop/references/fixes.md @@ -0,0 +1,368 @@ +# Fix patterns + +Before→after for each tell. These are *directions*, not find-and-replace rules — +adapt to the project's tokens and framework. Prefer changing a shared token or +component once over editing every call site. + +General order of operations: +1. Fix the design tokens / theme first (colors, radius, fonts). Many hits + disappear at once. +2. Then components, then one-off call sites. +3. Then copy. + +--- + +### 01 Indigo→violet gradient +```diff +- class="bg-gradient-to-r from-indigo-500 to-purple-500 shadow-lg shadow-purple-500/50" ++ class="bg-[--accent]" /* one solid, chosen accent */ +``` +If a gradient is truly wanted, make it subtle and directional (a single hue, +low contrast) and justify it. Drop the colored glow shadow. + +### 02 Gradient headline text +```diff +-

++

/* solid; go bigger/heavier for impact */ +``` + +### 03 Warm "cozy" palette +Replace the amber/stone wash with a near-neutral base and keep one warm accent: +```diff +- bg-[#fdf6ec] text-amber-900 border-amber-200 ++ bg-[--paper] text-[--ink] border-[--rule] /* accent stays for one element */ +``` +Propose the neutral values; let the user confirm before applying globally. + +### 04 Default semantic palette +Replace the framework rainbow with a coherent set derived from your palette: +neutrals plus at most one or two intentional semantic colours. +```diff +- Info +- Success +- Warning +- Error ++ Info Warning ++ Success /* one chosen green */ ++ Error /* the brand accent */ +``` + +### 05 One-hue status box +Carry the state in a word first; drop the border/text/tint all being one hue. +```diff +-
+- Something went wrong. +-
++
++ Error — something went wrong. /* one muted accent, on a neutral surface */ ++
+``` + +### 06 Gradients as atmosphere +Hold one flat background; build surface depth with a hairline, not a gradient. +```diff +- +-
+-
++ /* one flat colour, held */ ++
++
+``` +If a glow must exist, let it point at one element instead of filling the void. + +### 07 Serif-italic on one word +```diff +- The editor that actually ships. ++ The editor that ships. /* emphasise by weight/position, one voice */ +``` + +### 08 Serif where sans belongs +Swap the display serif for the project's UI sans in the font tokens; keep serif +only if the product's voice is genuinely editorial (and then use a *text* serif). +```diff +- font-family: "Playfair Display", serif; ++ font-family: var(--font-sans); +``` +For the oversized italic-serif hero: set it roman, or move to a non-serif +display face — the italic-serif hero is the AI-startup landing page uniform. + +### 09 Decorative strikes & highlights +Remove the strike/underline/highlight used for emphasis; let structure carry it. +```diff +-

The old new way to ship.

++

A faster way to ship.

+``` +Keep strike-through for a real edit, underline for a link, highlight for a real +annotation — each only when it does that job. + +### 10 The kicker above every heading +Delete kickers that restate their heading; keep one only where it adds a real +dimension (a category, a date, a genuine sequence number). +```diff +-

Features

+-

What it does

++

What it does

/* scale and space introduce the section */ +``` + +### 11 Full-sentence display headline +Compress the point into a few words at display size; move the rest to a +normal-size subline. +```diff +-

+- We help modern teams resolve incidents faster than ever before +-

++

Pages the right engineer

++

It maps alerts to code owners and routes ++ each incident to whoever shipped the change.

+``` +After shortening, re-check any `max-width` that was sized for the old headline — +a stale narrow measure (say `20ch`) will re-wrap your few words onto two lines +and undo the fix. Verify the rendered result, not just the copy. + +### 12 Flat type hierarchy +Give the scale real steps; let the most important thing be unmistakably bigger. +```diff +-

Usage this month

+-

API calls: 48,210 (+12% vs May)

++

API calls this month

++

48,210

++

+12% vs May

+``` +Merge sizes that are within a pixel or two; aim for ≥1.25× between steps. + +### 13 Highlighted keywords +```diff +- Our revolutionary platform helps +- ambitious teams move faster. ++ A project tracker for engineering teams. It updates issues from your commits. +``` +Let structure carry emphasis; at most one accented phrase. + +### 14 AI copywriting voice +Rewrite for specifics. Delete triads, em-dash drama, and "X theater" framing. +```diff +- It's not just an editor — it's a movement. Say goodbye to friction. ++ A code editor. Opens in under a second, ~half the memory of Electron editors. +- We killed the standup theater. ++ It replaces the daily standup with a three-line summary of yesterday's PRs. +``` + +### 15 Emoji everywhere +Remove emoji from headings, buttons, and bullets; keep one only where it carries +real information (a status). +```diff +-

🚀 Why you'll love it

++

Why teams pick it

+``` + +### 16 Glowing status dot +Drop the halo, the pulse, and the glow shadow; a flat dot plus a word is enough. +```diff +- +- +- +- Ready ++ Ready +``` + +### 17 Rounded card, colored left border +Collapse stacked callouts to body text; keep at most one real aside. +```diff +-
Note …
+-
Tip …
++

… the note, inline as normal prose …

++ +``` + +### 18 Rounded-square icon tiles +Drop decorative tiles; use a labelled list with real specifics. +```diff +-

Fast

Very fast.

++
  • Fast — cold start in 180 ms, measured on a 2020 laptop.
  • +``` + +### 19 Max radius + glassmorphism +Pick one small radius token; replace blur panes with solid surfaces. +```diff +- class="rounded-full backdrop-blur bg-white/10 border border-white/20" ++ class="rounded-[--radius] bg-[--card] border border-[--rule]" +``` + +### 20 Oversized drop shadow +Shrink the shadow to a real elevation: tight blur, small offset, low opacity, +never bigger than the element. Often a hairline replaces it outright. +```diff +- box-shadow: 0 16px 80px 10px rgba(0,0,0,0.36); /* a room-sized fog */ ++ border: 1px solid var(--rule); ++ box-shadow: 0 1px 2px rgba(0,0,0,0.06), 0 4px 10px -6px rgba(0,0,0,0.1); +``` +Keep it colorless; a tinted glow is not depth. (See tell 01 for the purple +glow-shadow, tell 16 for the status-dot halo.) Ghost-card variant: if a card +has both a hairline border and a wide soft shadow, keep one — usually the +hairline. + +### 21 Corners that don't nest +Compute the inner radius from the outer minus the padding, or don't round the +inner element at all. +```diff +-
    …
    /* both 16px */ ++
    …
    /* 16 − 12 ≈ 8 */ +``` + +### 22 Border that dies at the corner +Put the radius and the border on the same box; never let a clip eat a stroke. +```diff +-
    /* wrapper owns the radius… */ +- …
    /* …child owns the stroke — erased at every corner */ +-
    ++
    /* same box: the stroke wraps the arc */ ++ …
    ++
    +``` +Or keep the hairlines as dividers: run them straight, edge to edge, and drop +the radius from the fill — square-cornered regions need no arc. + +### 23 Badge & pill spam +Delete decorative pills; keep at most a real status tag. +```diff +-

    Dashboard

    ✨ New🔥 Popularβ Beta ++

    Dashboard

    v2.1 +``` + +### 24 AI-drawn SVG icons +Get a real, high-quality icon: commission a designer, or generate one with the +best image model and refine it until it's crisp and on-brand. +```diff +- … ++ /* a real icon: a designer, or a strong image model, refined */ +``` +Don't ship the crude blob the model sketched, and don't fall back to a bare +letter; a crude generated icon is worse than no picture at all. + +### 25 Icon in a tint of itself +Let the icon inherit the text color with no container; if it truly needs one, +give it a deliberate opaque surface. +```diff +-
    ++ /* just an icon; no tinted tile */ +``` + +### 26 The springy hover +Transition only the properties that carry the state change, at 120–200ms with a +standard ease; hover feedback is a surface shift, not growth. +```diff +- class="transition-all duration-500 hover:scale-105 hover:-translate-y-2 hover:shadow-2xl" ++ class="transition-colors duration-150 hover:bg-[--surface-2] hover:border-[--rule-strong]" +``` +Never animate layout properties (width/height/margin/padding); save spring +easing for things that genuinely move through space. + +### 27 The wobbling spinner +Give the rotation a fixed centre; never put centering and spinning in the same +`transform` — a keyframe replaces the whole property. +```diff +- .spinner { position: absolute; top: 50%; left: 50%; +- transform: translate(-50%, -50%); animation: spin 1s linear infinite; } +- @keyframes spin { to { transform: rotate(360deg); } } /* clobbers the translate */ ++ .spinner-box { position: absolute; inset: 0; display: grid; place-items: center; } ++ .spinner { animation: spin 1s linear infinite; } /* rotates in place */ +``` +Centre the artwork too: the SVG arc centred on its viewBox, `transform-origin` +left at center (or use the standalone `rotate` property, which composes with +`translate` instead of replacing it). Then watch one full revolution before +moving on. + +### 28 The all-caps card grid +Replace the grid of equal-weight cards with the single most important point, +told fully; drop the ALL-CAPS micro-labels. +```diff +-
    …6 icon+CAPS-label+one-liner cards…
    ++

    It replaces your standups.

    ++

    Every morning it reads yesterday's commits and PRs and writes the team a ++ three-line summary.

    +``` + +### 29 The invented stat row +Keep only measured, sourced numbers; delete the set dressing. +```diff +-
    10k+ Developers 99.9% Uptime 24/7 Support
    ++

    1,847 CI runs yesterday, median 3m 12s — from the public status page.

    +``` +One real, checkable figure beats three round ones. No numbers yet? Say what the +product does instead. + +### 30 The 01 / 02 / 03 section markers +Delete ornamental ordinals on unordered sections; number only real sequences. +```diff +- 01

    Collaborate

    +- 02

    Innovate

    ++

    Collaborate

    /* sections distinguished by scale and space */ +``` +Install steps, changelogs, and catalogues have earned their numbers; keep those. + +### 31 Cards inside cards +One surface per region; group inside it with spacing and hairlines. +```diff +-
    Pro — $8/mo
    ++
    ++

    Billing

    ++
    Plan Pro — $8/mo
    /* rows split by hairlines */ ++
    Renews May 3
    ++
    +``` +A child keeps its own surface only when it's genuinely a separate object (a +preview, an embed). + +### 32 One gap everywhere +Space by relationship: pull a heading toward its own rows, push groups apart. +```diff +-
    +-

    Profile

    Name

    Email

    +-

    Danger zone

    Delete workspace

    +-
    ++
    ++

    Profile

    Name

    Email

    ++
    ++
    ++

    Danger zone

    Delete workspace

    ++
    +``` +Use a small scale with real jumps (4/8/16/32/64), unevenly on purpose. + +### 33 Inter everywhere +Choose a face like you'd choose a logo: try a few, set your own copy in each, +and be able to say why this one. +```diff +- font-family: "Space Grotesk", sans-serif; /* display */ +- font-family: "Inter", sans-serif; /* body — the stock pairing */ ++ font-family: var(--font-sans); /* one face, compared and chosen — with the ++ reason recorded in the tokens file */ +``` +Landing back on Inter after comparing is a choice; starting there isn't. A +system stack picked for a reason is a choice too. Propose candidates; let the +user pick — never swap a brand font silently. + +### 34 The tasteful terminal +Move monospace back to code; use terminal metaphors only where they serve the +product. +```diff +- +-
      ╔══════════════╗
    +-          ║  WELCOME  ║
    +-          ╚══════════════╝
    ++ ++

    Welcome

    /* mono stays in /
     for actual code */
    +```
    +
    +### 35 The editorial dashboard
    +Type follows the job: sans + tabular lining numerals for scanned surfaces;
    +serif display only where the surface is genuinely editorial.
    +```diff
    +- 

    Good evening, Mara.

    +-

    The quiet rhythm of your services.

    +-
    3
    ++
    ++

    On-call — payments

    ++
    ++
    3
    +``` diff --git a/.claude/skills/kill-ai-slop/references/taxonomy.md b/.claude/skills/kill-ai-slop/references/taxonomy.md new file mode 100644 index 00000000..28a16dad --- /dev/null +++ b/.claude/skills/kill-ai-slop/references/taxonomy.md @@ -0,0 +1,343 @@ +# The AI-slop taxonomy + +35 tells, in two tiers. **Classic** = widely recognised. **Evolved** = newer +defaults that already read as templated. For each: what it is, why it reads as +machine-made, and the fix. Detection patterns live in `detection.md`; code +patches in `fixes.md`. + +The rule under every entry: slop is the *absence of a decision*. A tell is only +slop when it's a default nobody chose. The same element, chosen and defended, +is fine. + +## Color + +**01 · Indigo→violet gradient** — the `#6366f1 → #a855f7` diagonal on buttons, +glows, and hero backgrounds. It's the factory setting of Tailwind demos and +template galleries, so it reads as an absence of a decision. +*Fix:* one justified accent; if a gradient, give it direction and a reason, not +a "premium" sticker. + +**02 · Gradient headline text** — `background-clip:text` rainbow fills on +headings. Trades legibility and contrast for an effect every AI page uses; +decoration eats the message. +*Fix:* solid color for headings; build hierarchy with size, weight, and space. + +**03 · Warm "cozy" palette** — amber/stone/orange wash on soft beige. The +model's laziest translation of "warm and friendly": Tailwind's warm ramp served +raw, so every "human" product looks identical. +*Fix:* neutral base + one restrained warm accent; warmth comes from words. + +**04 · The default semantic palette** — info in blue/indigo, tip in amber, +success in green, error in red: the framework's stock `-50` backgrounds with +`-600` text. Nobody picked these hues; three or four unrelated candy colors, +related to nothing — not your brand, not each other. A bowl of default rainbow +candy where every box shouts a different colour. +*Fix:* grow semantic colours out of your one palette (tints of a hue plus +neutrals); colour only the states that truly differ. Most notes need no colour. + +**05 · The one-hue status box** — a status or alert where the border, text, and +background are all one hue, the background just a see-through version of it: red +on red, yellow on yellow, green on green. The framework reflex +(`border-red-500`, `text-red-500`, `bg-red-500/10`), one loud hue at three +opacities, nothing toned to sit next to your UI. Red/yellow/green is a traffic +light, not a palette. +*Fix:* carry the state in words and weight first — a bold "Error" reads before +colour does. If you colour at all, one muted accent from your own palette on a +neutral surface. + +**06 · Gradients as atmosphere** — a near-black blue page with a soft glow up +top and cards whose surface is itself a gradient, lighter above and darker +below: the default "premium dark mode." The glow and surface gradients carry no +information; it's the move a model makes to look premium in the dark, the same +midnight blue with a spotlight behind a thousand AI pages. Same family: +bento cards each washed in a tint of their own accent — the green card gets a +green gradient, the purple card a purple one — plus repeating-gradient stripes +as surface decoration, and colored box-shadow glows used as dark-mode accents. +*Fix:* pick one flat background and hold it; build surface depth with a hairline +and a restrained shadow. If a glow must exist, let it point at something. + +## Type + +**07 · Serif-italic on one word** — swapping one word in a sans headline to +serif italic for "emphasis." Cheap drama that stages two voices in one sentence; +the most viral AI headline tic. +*Fix:* emphasise with weight, position, or a line break; keep one voice, and if +you truly need italic use the same family's italic. + +**08 · Serif where sans belongs** — Playfair/Lora/Cormorant display serif on a +dev tool or SaaS, as body/UI text or as an oversized italic-serif hero +headline. The model equates "premium" with "serif"; display serifs are hard to +read at UI sizes and tonally off, a tuxedo on a terminal. The italic-serif hero +reads as taste in isolation — by now it's the universal AI-startup landing +page uniform. +*Fix:* one legible sans you chose; serif only when you want that voice, and a +text serif not a display one. Set the hero roman, or use a non-serif display +face (a genuinely editorial product may keep it — judge by context). + +**09 · Decorative strikes & highlights** — striking out, underlining, or +swiping a highlighter over a word, not to delete or annotate but as decorative +"emphasis." The same disease as serif-emphasis: the model doesn't trust the +words to carry weight, so it draws on them. +*Fix:* let weight, size, line breaks, and structure carry emphasis; keep strike +for real edits, underline for links, highlight for real annotation. + +**10 · The kicker above every heading** — a tiny uppercase, letter-spaced label +above the hero headline, then another above every section heading: FEATURES, +HOW IT WORKS, TESTIMONIALS. The kicker is an editorial device for *adding* +information (a section, a date, a category); the AI page stamps it as a reflex, +and the label restates what the heading already says. Zero information, worn as +a hat on every heading — a template's rhythm, not a decision. +*Fix:* delete any kicker that restates its heading; keep one only where it adds +a real dimension. Let scale and space introduce sections. + +**11 · The full-sentence display headline** — a whole marketing sentence set at +display size (`text-6xl font-extrabold tracking-tight`), wrapping to three or +four lines and eating the first viewport. Display sizes are for the two or +three words that can carry that scale; the model doesn't know which words yours +are, so it sets the whole pitch huge and crushes the tracking to look +"designed." Size standing in for the decision about what matters. (The +killaislop.com site itself did it — its definition section set a whole +sentence at display size until this entry forced the fix.) +*Fix:* compress the one thing into a few words and let those be big; say the +rest in a normal-size subline. Tighten tracking only as far as the face was +designed to go. + +**12 · The flat type hierarchy** — every size on the page crammed between 14 +and 18px: headings barely bigger than body, labels barely smaller, hierarchy +left entirely to shades of gray. The opposite failure of the display-size +sentence, and the same absence: nobody decided what matters most. The model +plays it safe (`text-lg` heading, `text-sm` everything else), so a reader +can't tell the page's one important thing from its footnotes. +*Fix:* a scale with few steps and real contrast between them (1.25× and up); +give the most important thing a size that says so. If two sizes are within a +pixel or two, merge them. + +## Copy + +**13 · Highlighted keywords** — coloring or bolding scattered words +mid-paragraph, like a highlighter ran over it. When every word is emphasised, +none is; it's the signature of not knowing the point. +*Fix:* let sentence structure carry emphasis — at most one accent per paragraph. + +**14 · The AI copywriting voice** — "It's not just X — it's Y", "Say goodbye to +X", punchy three-word triads, an em-dash habit, dismissing things as "X +theater." A rhythmic fingerprint: symmetrical, one notch too excited, +specifics-free. +*Fix:* write something specific — real numbers, nouns, consequences; say it the +way one person explains it to another. + +**15 · Emoji everywhere** — an emoji on every heading, button, and bullet: 🚀 +launch, ⚡ fast, 🔒 secure, 🎉 delight. Borrowed warmth standing in for tone +that copy and design should carry; a glyph before every point is louder, not +clearer. +*Fix:* cut decorative emoji from the UI; keep one only where it genuinely +carries information, like a real status. + +## Components + +**16 · The glowing status dot** — a status indicator (online / ready / live) +drawn as a solid dot in a pale halo, usually pulsing, almost always saturated +green; at its most decorative, glued into a hero marketing pill, playing status +for a tagline with no state behind it. A status is a tiny signal; the halo, +pulse, and breathing glow carry no information. Inflating a binary state into a +glowing gem is textbook overdesign. +*Fix:* a small, flat, single-color dot plus a word; no halo, no pulse. Colour +only when the state actually matters or changes — and if nothing is live behind +it, no dot at all. + +**17 · Rounded card, colored left border** — a pale rounded box with a 4px +colored bar down the left, wrapped around every list item, not just a "Tip/Note" +but feature points and changelog rows. A docs admonition turned into universal +decoration, so every row looks like an "important note" and none is. Sibling +move: a thick accent ring (`border-2` in a loud color) around a rounded card +to make one tier "pop." +*Fix:* let a list be a list — alignment, spacing, hierarchy. Callouts are +scarce: one or two a page, only for a genuine aside. + +**18 · Rounded-square icon tiles** — every feature gets a rounded-square chip + +a line icon, tiled into a grid. Shortest path to "looks designed"; the icons +rarely relate to the content, they just fill the grid. Same reflex at another +size: a giant decorative line icon (`w-24 h-24`) parked in a card as filler. +*Fix:* an icon must carry meaning or go; a clear label + one sentence beats a +row of glyphs. + +**19 · Max radius + glassmorphism** — everything a max-radius pill; every card a +translucent `backdrop-blur` pane. A frozen slice of 2021 Dribbble; effect +presets unrelated to what the product says. +*Fix:* one radius, held site-wide (usually small); depth from solid surfaces and +space, not blur. + +**20 · The oversized drop shadow** — a small card or icon casting a huge, soft +shadow that bleeds far past it on every side: big blur, low opacity, almost no +offset. A shadow says how high a surface floats, and real light gives a tight +contact shadow plus a soft ambient one with the blur tracking the height; this +one tracks nothing. A big soft blur reads as "depth" in a thumbnail but maps to +no real height — atmosphere standing in for elevation. Variant: the "ghost +card," a hairline border *plus* a wide diffuse shadow on the same card — two +separators doing one job; commit to an edge or an elevation, not both. +*Fix:* a small elevation scale, held: tight blur, small offset, low opacity, +never a shadow bigger than the thing casting it. Often a hairline does the +separating and no shadow is needed; keep it colorless — a tinted glow isn't depth. + +**21 · Corners that don't nest** — the same radius on every layer: a big-radius +outer box with an inner box at the same big radius, so the corners don't sit +concentrically and the inner arc fights the outer one. Nested corners have a +rule (inner = outer − gap); AI UIs skip the math and stamp one radius token on +everything. +*Fix:* compute the nested radius (inner = outer − padding), or don't round the +inner element at all; keep a small, deliberate radius scale. + +**22 · The border that dies at the corner** — a rounded rectangle with a 1px +hairline on its straight edges and nothing at the corners: the outline runs to +the tangent points and stops, the arcs go naked. It happens because the radius +and the border live on different boxes. The model memorized a recipe — content +that can't round its own corners (a table, an image, a list, a scroll area) +gets wrapped in `rounded-xl overflow-hidden` — while the child component ships +with its own 1px border or dividers. Every line is locally correct: the +wrapper owns the radius, the child owns the stroke. But the square stroke ring +falls outside the rounded clip at all four corners and gets erased; the +straight runs survive inside the clip. The model never renders its own output, +so it never sees the corner; a human sees the broken edge in one glance. The +fingerprint of code that's right line by line and wrong as a whole. +*Fix:* put the radius and the border on the same box — border + border-radius +on one element, and the stroke wraps the arc for free. If an outer layer +genuinely must clip content, move the border up onto the clipping layer too. +Inverse holds: if the lines are really dividers, keep them straight, run them +edge to edge, and don't round the fill. + +**23 · Badge and pill spam** — "✨ New", "β Beta", "🔥 Popular" decorative pills +everywhere. Manufacturing fake buzz; in bulk each stops meaning anything. +*Fix:* a badge only for real status (a version, stock). + +**24 · AI-drawn SVG icons** — asking a model to "draw an icon" and shipping what +comes back: a round blob with two dot eyes, a mascot of primitive shapes, a logo +that's a rounded square with a face. Vector art the machine can't really draw, +used as the product's mark; it looks like placeholder art that never got +replaced. +*Fix:* get a real, high-quality icon. Commission a designer, or generate one +with the best image model and refine it until it's crisp and on-brand. A crude +SVG the model sketched, or a bare fallback letter, isn't a mark worth shipping. + +**25 · Icon in a tint of itself** — every icon wrapped in a rounded square +filled with a see-through tint of its own color: a blue icon on faint blue, a +green one on faint green (`bg-{color}/10` behind `text-{color}`). A one-line +reflex — pad, round, wash in 10% of the icon's hue — so the page becomes a grid +of soft colored squares. +*Fix:* let an icon just be an icon; inherit the text color, no container. If +something genuinely needs one, give it a deliberate opaque surface from your +palette, not a tint of the icon it holds. + +## Motion + +**26 · The springy hover** — every card, button, and image wearing +`hover:scale-105` and `transition-all`: touch it and it grows, lifts, and +bounces on an elastic ease. Motion is information — what changed, where a thing +went, what's interactive — and scaling a card on hover says nothing; the card +isn't growing, and the user already knows the cursor is on it. `transition-all` +is the tell inside the tell: animating every property is the absence of +deciding which one means something. +*Fix:* transition only the properties that carry the state change (background, +border, opacity), 120–200ms, standard ease. Hover feedback is a surface shift, +not growth; save spring physics for things that genuinely move through space. + +**27 · The wobbling spinner** — a loading spinner that doesn't turn around its +own centre: the arc wobbles, tracing a little circle as it spins. Off-centre +artwork, a stray `transform-origin`, or a rotate keyframe that clobbers the +centering `translate(-50%, -50%)` (a keyframe replaces the whole `transform`). +A spinner has exactly one job — rotate in place around a fixed point — and +anyone who runs the page sees the wobble within half a second; shipping it is +an admission that nobody ever watched the interface move. Other tells are an +absence of taste; this one is an absence of looking. It lands at the worst +moment: the user has nothing to do but stare, and the most-watched element on +the page is limping. "Loading" reads as "broken." +*Fix:* give the rotation a real centre: artwork centred in its own box (the +SVG arc centred on the viewBox), `transform-origin` at center, and centering +kept out of the animated `transform` — position with a wrapper or the +standalone `rotate` property. Then watch one full revolution. + +## Layout + +**28 · The all-caps card grid** — an ALL-CAPS label plus a number or icon, +copied into rows of interchangeable cards: feature grids and dashboard +stat-cards alike. It fakes structure while stuffing unrelated things into +identical boxes; the ALL-CAPS micro-label is the default costume for "looks +designed." +*Fix:* decide the single most important thing and show it fully; if you must +list, use real hierarchy and contrast, not a grid of equal-weight cards. + +**29 · The invented stat row** — three big numbers in a row: 10k+ developers, +99.9% uptime, 24/7 support, on a product that launched yesterday. Social proof +turned into a layout, filled whether or not the proof exists; the numbers are +set dressing (a round 10k+, two nines, a 24/7), not measurements. Real numbers +are odd and specific — and one invented figure poisons every true one beside it. +*Fix:* show a number only if you measured it, and say where it came from; one +real, checkable figure beats three round ones. No numbers yet? Say what the +product does. + +**30 · The 01 / 02 / 03 section markers** — a giant faint ordinal beside every +marketing section (01 Collaborate, 02 Innovate, 03 Scale) as if they were steps +in a sequence. Numbering is a claim that these things happen in this order; +feature sections have no order, so the numerals are costume borrowed from +editorial design where an index means something. The cheapest way to look +structured without deciding on a structure. +*Fix:* number what's genuinely ordered — install steps, a changelog, a +catalogue — and delete the ornamental ordinals everywhere else. + +**31 · Cards inside cards** — a bordered, rounded, shadowed card holding +another card holding another, every layer with its own surface and padding. A +card claims its content is one self-contained thing; nested three deep, nothing +is contained and the padding stacks until content is a sliver. The model boxes +because a box is the only grouping move it trusts — grouping by spacing and +alignment requires deciding what belongs together. +*Fix:* one surface per region; inside it, group with spacing, alignment, and +hairline dividers. A child earns its own surface only when it's genuinely a +separate object (a preview, an embed). + +**32 · One gap everywhere** — `gap-4`, `p-4`, `space-y-4`: one spacing value +stamped across the page, so a heading sits exactly as far from its own body as +from the previous, unrelated section. Spacing is how a layout says what belongs +together — tight inside a group, generous between groups. One value everywhere +announces that nothing belongs to anything; proximity stops carrying +information, and the eye has to read every line to find the structure. +*Fix:* space by relationship, not by token: pull related lines close, push +unrelated groups apart. A small scale with real jumps (4/8/16/32/64), used +unevenly on purpose. + +## Evolved slop + +**33 · Inter everywhere** — Space Grotesk for display, Inter for body; or +Geist, Manrope, Plus Jakarta Sans. Every AI-built page draws from the same five +faces. They're good typefaces — that's the trap: the model reaches for them +because everyone did, and every product wearing them dissolves into the same +page. A typeface is the loudest single signal of who made this; outsourcing it +to the training-data average dodges the identity decision the way the indigo +gradient dodged the color one. +*Fix:* choose a face the way you'd choose a logo — try a few, set your own copy +in each, be able to say why this one. Landing back on Inter after that is a +choice; starting there isn't. (A system stack, picked for a reason, is a choice +too.) + +**34 · The "tasteful terminal"** — mono everywhere, a near-black background, one +warm accent, ASCII art: the look of "an AI that read one Vercel blog post." It +isn't ugly, that's the trap; it's polished enough to have become the new +default, dodging the design decision exactly like the indigo gradient did, in +cooler clothing. +*Fix:* keep monospace for code, not UI chrome; use ASCII art and terminal +metaphors only where they serve the product. Real taste is a choice you can +explain, not this season's safest template. + +**35 · The editorial dashboard** — an operational UI dressed as a magazine: a +giant serif "Good evening" greeting, serif numerals in the stat cards, cream +paper, a tracked-caps kicker over every block. The second-wave uniform: +scolded out of indigo gradients, the model's new shorthand for "taste" is +cream + display serif + one deep accent, stamped on any surface regardless of +its job. The greeting is lifted from a chat assistant's home screen, where the +greeting is the content; a dashboard's content is its numbers, and display +serifs with proportional oldstyle figures are prose type — the numerals you +can't scan down a column. A magazine is read once; a console is scanned all +day. +*Fix:* match the type to the job — a scanned interface takes a legible sans +with tabular lining numerals, hierarchy from scale and space. Display serif +belongs to genuinely editorial surfaces (docs, essays, a magazine). For +warmth, one material decision — a paper tint or an ink accent — not the full +costume. diff --git a/.claude/skills/kill-ai-slop/scripts/rules.ru.mjs b/.claude/skills/kill-ai-slop/scripts/rules.ru.mjs new file mode 100644 index 00000000..f7cd6a00 --- /dev/null +++ b/.claude/skills/kill-ai-slop/scripts/rules.ru.mjs @@ -0,0 +1,23 @@ +// Example --rules module: Russian AI-copywriting tells. +// +// node scan.mjs --rules=path/to/rules.ru.mjs +// +// Each entry has the same shape as a core tell in scan.mjs. Patterns may be +// RegExp or plain strings (strings are compiled case-insensitive). `copy: true` +// means the tell also reads prose (.md), not only code. Use this file as the +// template for your own language- or stack-specific rules. +// +// Note: JavaScript's \b is ASCII-only and never matches next to Cyrillic +// letters — write plain substrings or explicit boundaries instead. +export default [ + { id: "ru-14", group: "copy", name: "русский AI-слог", fix: "скажите конкретную вещь", + copy: true, + patterns: [ + /не просто .{1,40}?[—–-]\s*это/iu, + /попрощайтесь с|забудьте о том|представьте себе мир/iu, + /молниеносн|бесшовн|безупречн|революционн|беспрецедентн/iu, + /раскройте (?:весь )?потенциал/iu, + /выведите .{1,30} на новый уровень/iu, + /в считанные секунды/iu, + ] }, +]; diff --git a/.claude/skills/kill-ai-slop/scripts/scan.mjs b/.claude/skills/kill-ai-slop/scripts/scan.mjs new file mode 100755 index 00000000..dbc7cf9b --- /dev/null +++ b/.claude/skills/kill-ai-slop/scripts/scan.mjs @@ -0,0 +1,508 @@ +#!/usr/bin/env node +/* + kill-ai-slop scanner — dependency-free. + + Walks a project's frontend source and greps for the code-level signals of each + AI-slop tell (see references/detection.md). Prints a grouped report of + file:line hits. It NEVER edits files. Every hit is a lead to confirm by + reading the code, not a verdict. + + Usage: + node scan.mjs [root] [--json] [--no-color] + [--only=01,06] [--skip=19] [--exclude=path] [--rules=extra.mjs] + + Suppressing confirmed-intentional hits, in source comments: + deslop-ignore [ids…] suppress hits on the same line + deslop-ignore-next-line [ids…] suppress hits on the next line + deslop-ignore-file [ids…] suppress hits in the whole file + Without ids the directive suppresses every tell; with ids (e.g. 06 19) only those. +*/ + +import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, extname, join, relative, resolve } from "node:path"; + +const args = process.argv.slice(2); +const root = args.find((a) => !a.startsWith("-")) || "."; +const asJson = args.includes("--json"); +const normalizeId = (value) => (/^\d+$/.test(value) ? value.padStart(2, "0") : value); +const flagValues = (name) => + args + .filter((a) => a.startsWith(`--${name}=`)) + .flatMap((a) => a.slice(name.length + 3).split(",")) + .map((s) => s.trim()) + .filter(Boolean); +const onlyIds = new Set(flagValues("only").map(normalizeId)); +const skipIds = new Set(flagValues("skip").map(normalizeId)); +const excludes = flagValues("exclude"); +const rulesFiles = flagValues("rules"); +const useColor = + !args.includes("--no-color") && process.stdout.isTTY && !asJson; +// realpath both roots so the "skip the skill's own files" check still works +// when one path arrives through a symlink (macOS /var/folders vs /private/var). +const realpathOr = (p) => { + try { + return realpathSync(p); + } catch { + return p; + } +}; +const resolvedRoot = realpathOr(resolve(root)); +const skillRoot = realpathOr(resolve(dirname(fileURLToPath(import.meta.url)), "..")); +const escapeTerminal = (text) => text.replace(/[\0-\x1f\x7f-\x9f]/g, (char) => + `\\x${char.codePointAt(0).toString(16).padStart(2, "0")}`, +); + +const SKIP_DIRS = new Set([ + "node_modules", ".git", "dist", "build", "out", ".next", ".astro", + ".output", ".svelte-kit", ".nuxt", "coverage", "vendor", ".cache", + ".vercel", ".turbo", +]); +const EXTS = new Set([ + ".html", ".css", ".scss", ".sass", ".less", + ".tsx", ".jsx", ".ts", ".js", ".mjs", ".cjs", + ".vue", ".svelte", ".astro", ".md", ".mdx", + ".php", ".twig", +]); + +// A tell: id, human name, one-line fix, and the line patterns that flag it. +// `code` tells only look at code/style files; `copy` tells also read prose. +const TELLS = [ + { id: "01", group: "color", name: "indigo→violet gradient", fix: "one solid, chosen accent", + patterns: [ + /from-(indigo|violet|purple|fuchsia)-\d+[\s\S]{0,60}?to-(purple|violet|fuchsia|pink)-\d+/i, + /(linear-gradient|bg-gradient)[^;"'`]*(#6366f1|#8b5cf6|#a855f7|#7c3aed)/i, + /shadow-(purple|violet|indigo)-\d+\/\d+/i, + ] }, + { id: "02", group: "color", name: "gradient-clip headline", fix: "solid ink, scale up", + patterns: [ + /bg-clip-text[\s\S]{0,40}?text-transparent|text-transparent[\s\S]{0,40}?bg-clip-text/, + /(?:-webkit-)?background-clip:\s*text/i, + /-webkit-text-fill-color:\s*transparent/i, + ] }, + { id: "03", group: "color", name: "warm ‘cozy’ palette", fix: "neutral base + one warm accent", + patterns: [ + /\b(amber|orange|stone)-(50|100|200|300)\b/i, + /bg-\[#(?:fdf6ec|fef3e2|faf3e8|fff7ed|fdf4e3)\]/i, + /(text|border)-amber-\d+/i, + /text-(?:gray|slate|zinc|neutral)-(?:400|500)[^"'\n]{0,60}bg-(?:amber|stone|orange|rose|blue|indigo|green)-/i, + ] }, + { id: "04", group: "color", name: "default semantic palette", fix: "one palette: neutrals + a couple of chosen states", + patterns: [ + /bg-(?:blue|indigo)-50|bg-amber-50|bg-(?:green|emerald)-50|bg-red-50/i, + /(?:info|success|warning|error)[^\n]{0,30}(?:blue|green|amber|red)-(?:50|100|500|600|700)/i, + ] }, + { id: "05", group: "color", name: "one-hue status box", fix: "state in words; one muted accent on neutral", + patterns: [ + /border-(red|amber|yellow|green|blue)-\d+[\s\S]{0,60}?text-\1-\d+/i, + /bg-(?:red|amber|yellow|green)-\d+\/(?:5|10|15|20)\b/i, + /(?:error|warning|success)[^\n]{0,40}(?:red|amber|yellow|green)-\d+/i, + ] }, + { id: "06", group: "color", name: "gradients as atmosphere", fix: "one flat bg; depth from a hairline", + patterns: [ + /radial-gradient/i, + /linear-gradient[^;)]*(?:to bottom|180deg|to top)/i, + /bg-gradient-to-[bt]\b[\s\S]{0,40}?from-/i, + /repeating-(?:linear|radial)-gradient/i, + /bg-gradient-to-(?:br|tr|bl|tl)\b[\s\S]{0,40}?from-(?:emerald|green|teal|cyan|purple|violet|fuchsia)-\d+\/(?:5|10|15|20|25)/i, + /box-shadow:[^;{}]*(?:#(?:6366f1|8b5cf6|a855f7|22d3ee|06b6d4)|rgba?\(\s*(?:139|168))/i, + ] }, + { id: "07", group: "type", name: "serif-italic emphasis", fix: "emphasise by weight, one voice", + patterns: [ + /font-serif\b/i, + /font-family:\s*(?:georgia|"?playfair|"?lora|"?cormorant)/i, + ] }, + { id: "08", group: "type", name: "serif where sans belongs", fix: "one legible UI sans", + patterns: [ + /font-family:\s*[^;]*(playfair|cormorant|lora|dm serif|libre baskerville)/i, + /fontFamily[^;\n]*(Playfair|Cormorant|Lora)/, + /font-serif[^"'\n]{0,60}(?:italic|text-[4-9]xl)|(?:\bitalic\b|text-[4-9]xl)[^"'\n]{0,60}font-serif/i, + ] }, + { id: "09", group: "type", name: "decorative strikes & highlights", fix: "strike for edits, underline for links", + patterns: [ + /\bline-through\b/i, + /<(?:mark|s|u|del|strike)[\s>]/i, + /text-decoration:\s*(?:line-through|underline)/i, + ] }, + { id: "10", group: "type", name: "kicker above every heading", fix: "delete kickers that restate the heading", + patterns: [ + /\buppercase\b[\s\S]{0,40}?tracking-(?:wide|wider|widest)\b|tracking-(?:wide|wider|widest)\b[\s\S]{0,40}?\buppercase\b/i, + /\b(?:eyebrow|kicker|overline)\b/i, + /text-transform:\s*uppercase[\s\S]{0,80}?letter-spacing:\s*0?\.\d+em/i, + ] }, + { id: "11", group: "type", name: "full-sentence display headline", fix: "few words big; specifics in a subline", + patterns: [ + /\btext-(?:5|6|7|8|9)xl\b/, + /tracking-tighter?\b[\s\S]{0,40}?font-(?:extrabold|black)|font-(?:extrabold|black)[\s\S]{0,40}?tracking-tighter?\b/i, + /font-size:\s*(?:[5-9]\d(?:\.\d+)?px|[4-9](?:\.\d+)?rem)/i, + /letter-spacing:\s*-0?\.0[5-9]/i, + ] }, + { id: "12", group: "type", name: "flat type hierarchy", fix: "few steps, ≥1.25× between them", + patterns: [ + /]*text-(?:sm|base|lg)\b/i, + ] }, + { id: "13", group: "copy", name: "highlighted keywords", fix: "let structure carry emphasis", + copy: true, + patterns: [ + /]/i, + /text-(primary|indigo|purple|violet)-\d+/, + ] }, + { id: "14", group: "copy", name: "AI copywriting voice", fix: "say the specific thing", + copy: true, + patterns: [ + /not just .{1,40}\bit(?:['’])?s\b/i, + /\b(say goodbye to|meet your new|supercharge|unlock the power of|in seconds,? not)\b/i, + /\b(blazing[- ]fast|effortless(?:ly)?|seamless(?:ly)?|game[- ]?changer|next[- ]level)\b/i, + /\b(?:growth|security|process|privacy|productivity|compliance|feature|innovation) theater\b/i, + ] }, + { id: "15", group: "copy", name: "emoji everywhere", fix: "cut emoji from product copy", + copy: true, + patterns: [ + /\p{Extended_Pictographic}/u, + ] }, + { id: "16", group: "component", name: "glowing status dot", fix: "flat dot + a word; no halo", + patterns: [ + /\banimate-(?:ping|pulse)\b/i, + /shadow-(?:green|emerald|lime)-\d+\/\d+/i, + /(?:ready|online|live)[\s\S]{0,40}?(?:●|rounded-full)/i, + ] }, + { id: "17", group: "component", name: "left-border callout", fix: "one aside, rest is body", + patterns: [ + /border-l-4[\s\S]{0,40}?rounded|rounded[\s\S]{0,40}?border-l-4/i, + /\b(admonition|callout|note-box)\b/i, + /\bborder-2\b[\s\S]{0,40}?border-(indigo|purple|violet|blue|pink|green)-\d+/i, + ] }, + { id: "18", group: "component", name: "pastel icon tiles", fix: "labelled list with specifics", + patterns: [ + /rounded-(lg|xl|2xl)\s+bg-(indigo|purple|blue|green|amber|pink)-(50|100)/i, + /]*(?:width|height)="(?:9[6-9]|[1-9]\d{2})"/i, + ] }, + { id: "19", group: "component", name: "max-radius / glassmorphism", fix: "one small radius, solid surfaces", + patterns: [ + /\brounded-full\b/, + /backdrop-blur|backdrop-filter:\s*blur|bg-(?:white|black)\/(?:5|10|20|30)/i, + /border-radius:\s*(?:9999px|50%|2rem|24px)/i, + ] }, + { id: "20", group: "component", name: "oversized drop shadow", fix: "tight elevation, never bigger than the element", + patterns: [ + /box-shadow:[^;{}]*\b(?:[6-9]\d|\d{3,})px/i, + /shadow-\[[^\]]*\b(?:[6-9]\d|\d{3,})px/i, + /filter:[^;{}]*drop-shadow\([^)]*\b(?:[6-9]\d|\d{3,})px/i, + /\bborder\b[\s\S]{0,40}?shadow-(?:xl|2xl)\b|shadow-(?:xl|2xl)\b[\s\S]{0,40}?\bborder\b/, + ] }, + { id: "21", group: "component", name: "corners that don't nest", fix: "inner = outer − padding", + patterns: [ + /\brounded-(?:xl|2xl|3xl)\b/i, + /border-radius:\s*(?:1rem|1\.5rem|24px|32px)/i, + ] }, + { id: "22", group: "component", name: "border dies at the corner", fix: "radius and border on the same box", + patterns: [ + /rounded-(?:lg|xl|2xl|3xl)[^"'\n]{0,60}overflow-(?:hidden|clip)|overflow-(?:hidden|clip)[^"'\n]{0,60}rounded-(?:lg|xl|2xl|3xl)/i, + /clip-path:\s*inset\([^)]*round/i, + /\bborder-[trbl]\b[^"'\n]{0,60}rounded-(?:lg|xl|2xl|3xl)|rounded-(?:lg|xl|2xl|3xl)[^"'\n]{0,60}\bborder-[trbl]\b/i, + ] }, + { id: "23", group: "component", name: "badge / pill spam", fix: "badges only for real status", + patterns: [ + /rounded-full[\s\S]{0,40}?bg-(indigo|purple|green|amber|pink)-(50|100|200)/i, + />\s*(?:[✨🔥🎉🚀]\s*)?(new|beta|hot|popular|pro|coming soon)\s*]*\br="[1-9]/i, + /(?:mascot|blob)\.svg/i, + ] }, + { id: "25", group: "component", name: "icon in a tint of itself", fix: "no tinted tile; inherit text color", + patterns: [ + /bg-(indigo|blue|green|amber|red|purple|pink)-\d+\/(?:5|10|15|20)[\s\S]{0,60}?text-\1-/i, + /text-(indigo|blue|green|amber|red|purple|pink)-\d+[\s\S]{0,60}?bg-\1-\d+\/(?:5|10|15|20)/i, + ] }, + { id: "26", group: "motion", name: "springy hover", fix: "transition what changes, 120–200ms, standard ease", + patterns: [ + /hover:(?:scale-1[01]\d|-translate-y-)/i, + /\btransition-all\b/, + /cubic-bezier\([^)]*,\s*1\.[2-9]/, + /\banimate-bounce\b/, + /transition:[^;{}]*\b(?:width|height|margin|padding)\b/i, + ] }, + // Leads only — the wobble itself is visual. A centering translate near a spin + // keyframe is the classic clobber; an off-centre transform-origin near a spin + // animation is the other spelling of the same bug. + { id: "27", group: "motion", name: "wobbly spinner", fix: "fixed rotation centre; keep centering out of the animated transform", + patterns: [ + /translate\(-50%,\s*-50%\)[\s\S]{0,600}?@keyframes\s+[\w-]*(?:spin|rotate|load)/i, + /@keyframes\s+[\w-]*(?:spin|rotate|load)[\w-]*\s*\{[\s\S]{0,160}?transform:\s*rotate\([^)]*\)\s*;?\s*\}[\s\S]{0,600}?translate\(-50%,\s*-50%\)/i, + /(?:\banimate-spin\b|animation:[^;{}]*\b[\w-]*spin)[\s\S]{0,200}?transform-origin:\s*(?!center\b|50%\s*50%)[\w.% -]/i, + /transform-origin:\s*(?!center\b|50%\s*50%)[\w.% -]+;[\s\S]{0,200}?(?:\banimate-spin\b|animation:[^;{}]*\b[\w-]*spin)/i, + ] }, + { id: "28", group: "layout", name: "all-caps card grid", fix: "show the one key thing fully", + patterns: [ + /\bgrid-cols-3\b/, + /\buppercase\b[\s\S]{0,30}?(?:text-xs|tracking-wide)/i, + /\b(everything you need|why (?:you.?ll love|choose|teams))\b/i, + ] }, + { id: "29", group: "layout", name: "invented stat row", fix: "only measured, sourced numbers", + copy: true, + patterns: [ + /\b\d+[km]\+[\s\S]{0,30}?(?:developers|users|teams|customers|downloads|stars)/i, + /99\.9+%/, + /\b24\/7\b/, + ] }, + { id: "30", group: "layout", name: "01/02/03 section markers", fix: "number only real sequences", + patterns: [ + /['"`>]0[1-9]['"`<]/, + /text-[789]xl[\s\S]{0,50}?(?:text-(?:gray|slate|zinc|neutral)-(?:100|200)|opacity-(?:5|10|20))/i, + /\bstep[- ](?:one|two|three)\b/i, + ] }, + { id: "31", group: "layout", name: "cards inside cards", fix: "one surface per region; hairlines inside", + patterns: [ + /<(Card|Panel|Box)[^>]*>\s*<\1\b/, + ] }, + { id: "32", group: "layout", name: "one gap everywhere", fix: "space by relationship, not by token", + patterns: [ + /(space-y-4|gap-4)\b[\s\S]{0,120}?\b(space-y-4|gap-4)\b/, + ] }, + { id: "33", group: "evolved", name: "Inter everywhere", fix: "compare faces; be able to say why this one", + patterns: [ + /fonts\.googleapis\.com\/css2\?family=(?:Inter|Space\+Grotesk|Manrope|Plus\+Jakarta)/i, + /font-family:\s*[^;]*(?:\bInter\b|Space Grotesk|Manrope|Plus Jakarta Sans|\bGeist\b)/, + /\b(?:Inter|Space_Grotesk|Manrope|Plus_Jakarta_Sans)\b[\s\S]{0,60}?next\/font\/google|next\/font\/google[\s\S]{0,60}?\b(?:Inter|Space_Grotesk|Manrope|Plus_Jakarta_Sans)\b/, + ] }, + { id: "34", group: "evolved", name: "tasteful-terminal", fix: "mono for code only", + patterns: [ + /\bfont-mono\b/, + /font-family:\s*[^;]*(?:mono|jetbrains|fira code|ibm plex mono|geist mono)/i, + /[╔╗╚╝║═▓▒░]/, + ] }, + // Editorial serif faces beyond tell 08's list, the greeting-as-headline, and + // opt-in oldstyle figures — the "magazine dashboard" kit. Serif on genuinely + // editorial surfaces (docs, essays) is not this tell; confirm before fixing. + { id: "35", group: "evolved", name: "editorial-dashboard", fix: "sans + tabular numerals for scanned UI", + patterns: [ + />\s*Good (?:morning|afternoon|evening),/i, + /font-family:\s*[^;]*(?:fraunces|canela|tiempos|didot|freight|reckless|newsreader)/i, + /oldstyle-nums|font-variant-numeric:\s*oldstyle/i, + ] }, +]; + +// Extra rule modules (--rules=file.mjs): each exports an array of tells shaped +// like the entries above. Patterns may be RegExp or plain strings (compiled +// case-insensitive). Lets language- or stack-specific rules live outside core. +for (const rulesPath of rulesFiles) { + let extra; + try { + const mod = await import(pathToFileURL(resolve(rulesPath)).href); + extra = mod.default ?? mod.tells; + } catch (err) { + console.error(`Could not load rules file ${escapeTerminal(rulesPath)}: ${err.message}`); + process.exit(1); + } + if (!Array.isArray(extra)) { + console.error(`Rules file ${escapeTerminal(rulesPath)} must export an array of tells`); + process.exit(1); + } + for (const tell of extra) { + if (!tell || typeof tell.id !== "string" || typeof tell.name !== "string" || + !Array.isArray(tell.patterns) || tell.patterns.length === 0) { + console.error(`Rules file ${escapeTerminal(rulesPath)}: each tell needs a string id, a name, and a non-empty patterns array`); + process.exit(1); + } + TELLS.push({ + id: tell.id, + group: tell.group || "custom", + name: tell.name, + fix: tell.fix || "", + copy: Boolean(tell.copy), + patterns: tell.patterns.map((p) => (p instanceof RegExp ? p : new RegExp(p, "iu"))), + }); + } +} + +const isExcluded = (path) => { + if (excludes.length === 0) return false; + const rel = relative(resolvedRoot, path); + return excludes.some((token) => rel.includes(token)); +}; + +function walk(dir, files = []) { + if (resolve(dir) === skillRoot) return files; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return files; + } + for (const e of entries) { + if (e.name.startsWith(".") && e.name !== ".") { + if (SKIP_DIRS.has(e.name)) continue; + } + const full = join(dir, e.name); + if (isExcluded(full)) continue; + if (e.isDirectory()) { + if (SKIP_DIRS.has(e.name) || resolve(full) === skillRoot) continue; + walk(full, files); + } else if (e.isFile()) { + const base = e.name; + if (/\.min\.(js|css)$/.test(base)) continue; + if (/(package-lock|pnpm-lock|yarn\.lock)/.test(base)) continue; + const ext = extname(base); + if (EXTS.has(ext) || /^tailwind\.config\./.test(base)) files.push(full); + } + } + return files; +} + +function scanFile(path) { + let text; + try { + const st = statSync(path); + if (st.size > 512 * 1024) return []; // skip large/generated files + text = readFileSync(path, "utf8"); + } catch { + return []; + } + const isCode = extname(path) !== ".md"; + const lines = text.split(/\r?\n/); + + // deslop-ignore directives, parsed once per file. Ids are the tokens after + // the directive that contain a digit ("06", "ru-14"); none means all tells. + const parseIds = (tail) => { + const ids = (tail.match(/[\w-]+/g) || []).filter((t) => /\d/.test(t)).map(normalizeId); + return ids.length ? new Set(ids) : null; // null = every tell + }; + const lineIgnores = new Map(); // lineIndex -> null (all) | Set of ids + let fileIgnore; // undefined | null (all) | Set of ids + const addLineIgnore = (idx, ids) => { + if (idx < 0 || idx >= lines.length || lineIgnores.get(idx) === null) return; + if (ids === null) return void lineIgnores.set(idx, null); + const set = lineIgnores.get(idx) || new Set(); + for (const id of ids) set.add(id); + lineIgnores.set(idx, set); + }; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(/deslop-ignore(-file|-next-line)?\b(.*)$/); + if (!m) continue; + const ids = parseIds(m[2]); + if (m[1] === "-file") { + if (ids === null) fileIgnore = null; + else if (fileIgnore !== null) { + fileIgnore = fileIgnore || new Set(); + for (const id of ids) fileIgnore.add(id); + } + } else if (m[1] === "-next-line") addLineIgnore(i + 1, ids); + else addLineIgnore(i, ids); + } + if (fileIgnore === null) return []; + const isSuppressed = (id, lineIndex) => { + if (fileIgnore && fileIgnore.has(id)) return true; + const ig = lineIgnores.get(lineIndex); + return ig === null || (ig !== undefined && ig.has(id)); + }; + + const lineStarts = [0]; + for (let index = text.indexOf("\n"); index !== -1; index = text.indexOf("\n", index + 1)) { + lineStarts.push(index + 1); + } + const hits = []; + const seen = new Set(); + for (const tell of TELLS) { + if (onlyIds.size > 0 && !onlyIds.has(tell.id)) continue; + if (skipIds.has(tell.id)) continue; + if (!tell.copy && !isCode) continue; // code-only tell in a prose file + for (const pattern of tell.patterns) { + const matcher = new RegExp(pattern.source, `${pattern.flags}g`); + let match; + while ((match = matcher.exec(text))) { + let low = 0; + let high = lineStarts.length - 1; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if (lineStarts[middle] <= match.index) low = middle; + else high = middle - 1; + } + const lineIndex = low; + const line = lines[lineIndex]; + if (line.length > 2000) continue; // minified-ish + const key = `${tell.id}:${lineIndex}`; + if (!seen.has(key) && !isSuppressed(tell.id, lineIndex)) { + seen.add(key); + hits.push({ tell, line: lineIndex + 1, text: line.trim().slice(0, 100) }); + } + if (match[0] === "") matcher.lastIndex += 1; + } + } + } + return hits; +} + +// ---- run ---- +try { + if (!statSync(resolvedRoot).isDirectory()) { + throw new Error("not a directory"); + } +} catch { + console.error(`Scan root must be an existing directory: ${escapeTerminal(root)}`); + process.exit(1); +} + +const files = walk(resolvedRoot); +const byTell = new Map(); // id -> { tell, hits: [{file,line,text}] } +for (const f of files) { + for (const h of scanFile(f)) { + if (!byTell.has(h.tell.id)) byTell.set(h.tell.id, { tell: h.tell, hits: [] }); + byTell.get(h.tell.id).hits.push({ file: relative(resolvedRoot, f) || f, line: h.line, text: h.text }); + } +} + +const groups = [...byTell.values()].sort((a, b) => a.tell.id.localeCompare(b.tell.id)); +const totalHits = groups.reduce((n, g) => n + g.hits.length, 0); + +if (asJson) { + console.log( + JSON.stringify( + { + root, + filesScanned: files.length, + groups: groups.length, + hits: totalHits, + findings: groups.map((g) => ({ + id: g.tell.id, + group: g.tell.group, + name: g.tell.name, + fix: g.tell.fix, + hits: g.hits, + })), + }, + null, + 2, + ), + ); + process.exit(0); +} + +const c = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s); +const red = (s) => c("31", s); +const dim = (s) => c("2", s); +const bold = (s) => c("1", s); + +console.log(`\n${bold("kill-ai-slop")} — scanned ${files.length} files under ${escapeTerminal(root)}\n`); +if (groups.length === 0) { + console.log("No slop signals found. (Still trust your eyes — open the pages.)\n"); + process.exit(0); +} + +for (const g of groups) { + console.log(`${red("slop")} ${bold(g.tell.id)} ${g.tell.name} ${dim("→ " + g.tell.fix)}`); + const shown = g.hits.slice(0, 12); + for (const h of shown) { + console.log(` ${dim(escapeTerminal(h.file + ":" + h.line))} ${escapeTerminal(h.text)}`); + } + if (g.hits.length > shown.length) { + console.log(dim(` … and ${g.hits.length - shown.length} more`)); + } + console.log(""); +} + +console.log( + `${bold("→")} ${groups.length} groups, ${totalHits} hits. ` + + dim("Confirm each by reading the code, then fix per references/fixes.md.\n"), +); diff --git a/.claude/skills/kill-ai-slop/scripts/scan.test.mjs b/.claude/skills/kill-ai-slop/scripts/scan.test.mjs new file mode 100644 index 00000000..0854ae74 --- /dev/null +++ b/.claude/skills/kill-ai-slop/scripts/scan.test.mjs @@ -0,0 +1,202 @@ +import assert from "node:assert/strict"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const scriptPath = join(dirname(fileURLToPath(import.meta.url)), "scan.mjs"); + +function withTempProject(run) { + const project = mkdtempSync(join(tmpdir(), "kill-ai-slop-")); + try { + return run(project); + } finally { + rmSync(project, { recursive: true, force: true }); + } +} + +function scan(root, ...args) { + return spawnSync(process.execPath, [scriptPath, root, ...args], { + encoding: "utf8", + }); +} + +function reportFor(root) { + const result = scan(root, "--json"); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(result.stdout); +} + +function finding(report, id) { + return report.findings.find((entry) => entry.id === id); +} + +test("rejects a missing scan root instead of reporting a clean scan", () => { + withTempProject((project) => { + const control = "\u001b]52;c;not-a-clipboard\u0007"; + const result = scan(join(project, `missing-${control}`), "--json"); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Scan root must be an existing directory/); + assert.equal(result.stderr.includes(control), false); + assert.ok(result.stderr.includes("\\x1b]52;c;not-a-clipboard\\x07")); + assert.equal(result.stdout, ""); + + const file = join(project, "source.css"); + writeFileSync(file, ""); + const fileResult = scan(file, "--json"); + assert.notEqual(fileResult.status, 0); + assert.match(fileResult.stderr, /Scan root must be an existing directory/); + }); +}); + +test("finds multiline patterns and documented runtime signals", () => { + withTempProject((project) => { + writeFileSync( + join(project, "signals.tsx"), + `
    \n

    not just fast — it’s reliable

    \n`, + ); + writeFileSync(join(project, "Page.mdx"), `
    \n`); + writeFileSync( + join(project, "signals.css"), + `.wash { background: bg-gradient-to-br from-emerald-500/10; }\n` + + `.glow { box-shadow: 0 0 1rem #6366f1; }\n` + + `.kicker { text-transform: uppercase; letter-spacing: 0.1em; }\n` + + `.panel { transition: width 200ms ease; }\n` + + `.steps::before { content: "step one"; }\n` + + `.body { font-family: Geist, sans-serif; }\n` + + `.load { top: 50%; left: 50%; transform: translate(-50%, -50%); animation: spin 1s linear infinite; }\n` + + `@keyframes spin { to { transform: rotate(360deg); } }\n`, + ); + + const report = reportFor(project); + assert.equal(finding(report, "01")?.hits[0].line, 1); + for (const id of ["02", "06", "10", "14", "26", "27", "30", "33"]) { + assert.ok(finding(report, id), `expected tell ${id} to be detected`); + } + }); +}); + +test("skips the installed skill's own files", () => { + withTempProject((project) => { + const installedScript = join( + project, + ".claude", + "skills", + "kill-ai-slop", + "scripts", + "scan.mjs", + ); + mkdirSync(dirname(installedScript), { recursive: true }); + copyFileSync(scriptPath, installedScript); + + const result = spawnSync(process.execPath, [installedScript, project, "--json"], { + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).filesScanned, 0); + }); +}); + +test("scans PHP and Twig templates", () => { + withTempProject((project) => { + writeFileSync( + join(project, "header.php"), + `

    \n`, + ); + writeFileSync( + join(project, "card.twig"), + `
    {{ title }}
    \n`, + ); + + const report = reportFor(project); + assert.equal(report.filesScanned, 2); + assert.ok(finding(report, "02"), "expected tell 02 in the PHP template"); + assert.ok(finding(report, "19"), "expected tell 19 in the Twig template"); + }); +}); + +test("deslop-ignore directives suppress hits", () => { + withTempProject((project) => { + writeFileSync( + join(project, "styles.css"), + `.a { background: radial-gradient(circle, red, blue); } /* deslop-ignore */\n` + + `/* deslop-ignore-next-line 06 */\n` + + `.b { background: radial-gradient(circle, red, blue); }\n` + + `/* deslop-ignore-next-line 01 */\n` + + `.c { background: radial-gradient(circle, red, blue); }\n`, + ); + writeFileSync( + join(project, "ignored.css"), + `/* deslop-ignore-file */\n.x { backdrop-filter: blur(10px); }\n`, + ); + + const report = reportFor(project); + const atmosphere = finding(report, "06"); + assert.equal(atmosphere?.hits.length, 1); // only .c: its directive names another tell + assert.equal(atmosphere.hits[0].line, 5); + assert.equal(finding(report, "19"), undefined); + }); +}); + +test("--only, --skip and --exclude narrow the scan", () => { + withTempProject((project) => { + writeFileSync( + join(project, "a.css"), + `.x { background: radial-gradient(red, blue); backdrop-filter: blur(4px); }\n`, + ); + mkdirSync(join(project, "legacy")); + writeFileSync(join(project, "legacy", "b.css"), `.y { background: radial-gradient(red, blue); }\n`); + + const only = scan(project, "--json", "--only=6"); + assert.equal(only.status, 0, only.stderr); + assert.deepEqual(JSON.parse(only.stdout).findings.map((f) => f.id), ["06"]); + + const skip = scan(project, "--json", "--skip=06"); + assert.equal(skip.status, 0, skip.stderr); + const skipReport = JSON.parse(skip.stdout); + assert.equal(skipReport.findings.some((f) => f.id === "06"), false); + assert.ok(skipReport.findings.some((f) => f.id === "19")); + + const excluded = scan(project, "--json", "--exclude=legacy"); + assert.equal(excluded.status, 0, excluded.stderr); + assert.equal(JSON.parse(excluded.stdout).filesScanned, 1); + }); +}); + +test("--rules loads extra tells (Russian example rules)", () => { + withTempProject((project) => { + writeFileSync(join(project, "copy.md"), `Это не просто сканер — это ваш новый помощник.\n`); + + const rulesPath = join(dirname(scriptPath), "rules.ru.mjs"); + const result = scan(project, "--json", `--rules=${rulesPath}`); + assert.equal(result.status, 0, result.stderr); + assert.ok(finding(JSON.parse(result.stdout), "ru-14")); + + const badRules = join(project, "bad-rules.mjs"); + writeFileSync(badRules, `export default {};\n`); + const bad = scan(project, "--json", `--rules=${badRules}`); + assert.notEqual(bad.status, 0); + assert.match(bad.stderr, /must export an array/); + }); +}); + +test("escapes control characters in human output", () => { + withTempProject((project) => { + const control = "\u001b]52;c;not-a-clipboard\u0007"; + writeFileSync( + join(project, "unsafe.css"), + `.x { background: linear-gradient(to bottom, red, blue); ${control} }`, + ); + + const result = scan(project, "--no-color"); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.includes(control), false); + assert.ok(result.stdout.includes("\\x1b]52;c;not-a-clipboard\\x07")); + + const jsonResult = scan(project, "--json"); + assert.equal(jsonResult.status, 0, jsonResult.stderr); + assert.doesNotThrow(() => JSON.parse(jsonResult.stdout)); + }); +}); diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..bc940e28 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.rs] +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..79ce2122 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto eol=lf +*.png binary +*.ico binary +*.icns binary +*.woff binary +*.woff2 binary +*.ttf binary +pnpm-lock.yaml -diff diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..47bc6f5c --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @lumenpearson diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..e6d644d4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,40 @@ +name: Bug report +description: Something in Lumen OS does not behave as expected +labels: [bug] +body: + - type: dropdown + id: host + attributes: + label: Host + options: + - Web (browser) + - Desktop — Windows + - Desktop — macOS + - Desktop — Linux + - Landing site + validations: + required: true + - type: input + id: version + attributes: + label: Version / commit + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: 1. Open Files → 2. … + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected + - type: textarea + id: actual + attributes: + label: Actual + - type: input + id: viewport + attributes: + label: Viewport / display scale + placeholder: 1920×1080 @ 125% diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..aec43304 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Question or idea + url: https://github.com/lumenpearson/os/discussions + about: For anything that is not a defect or a concrete feature request. + - name: Security vulnerability + url: https://github.com/lumenpearson/os/security/advisories/new + about: Report privately. Do not open a public issue for a vulnerability. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..aab15639 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,19 @@ +name: Feature request +description: Propose an app, a setting, or a shell behaviour +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: What are you trying to do? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal + - type: dropdown + id: area + attributes: + label: Area + options: [shell, apps, kernel, vfs, desktop (Rust), landing, tooling] diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..e6c0afd1 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,17 @@ +## What + + + +## How to verify + + + +## Checklist + +- [ ] `pnpm check` passes +- [ ] `cargo clippy --workspace --all-targets -- -D warnings` passes (if Rust changed) +- [ ] `pnpm deslop` reviewed (if UI changed) and remaining hits explained +- [ ] Works at a small viewport (≤ 800×600) and a large one (≥ 2560 wide) +- [ ] Keyboard reachable, focus visible +- [ ] Screenshots attached for visual changes +- [ ] AI-assisted authorship disclosed per `AI_USAGE_POLICY.md` diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md new file mode 100644 index 00000000..52ecfddd --- /dev/null +++ b/.github/SUPPORT.md @@ -0,0 +1,44 @@ +# Getting help + +## Before you ask + +Most questions are answered by three files in the repository root: + +- `README.md` — what Lumen OS is, and how to run it. +- `ARCHITECTURE.md` — how the packages fit together and which one owns what. +- `CONTRIBUTING.md` — the workflow, the commands, and what a change has to pass. + +If you are trying to build the desktop app, `docs/BUILDING.md` covers the +per-platform prerequisites. + +## Where to put what + +| You have | Use | +| --- | --- | +| Something behaves incorrectly | [Bug report](https://github.com/lumenpearson/os/issues/new?template=bug_report.yml) | +| Something is missing | [Feature request](https://github.com/lumenpearson/os/issues/new?template=feature_request.yml) | +| A question, or an idea that is not yet a request | [Discussions](https://github.com/lumenpearson/os/discussions) | +| A security vulnerability | [Private advisory](https://github.com/lumenpearson/os/security/advisories/new) — see `SECURITY.md`. Never a public issue. | + +## What makes a bug report useful here + +Lumen OS runs in two quite different hosts, and most reports that go nowhere +are the ones where nobody can tell which host was involved. So please say: + +- **Which host** — a browser (and which one), or the desktop build (and which + operating system). The file system, the window manager and the system + information all differ between them. +- **Which version or commit.** System Information → Copy Report puts the + build, the host and the browser on your clipboard in one go. +- **What you did, what you expected, and what happened instead** — in that + order, and concretely enough to follow. + +If the problem is visual, a screenshot is worth more than a description. If it +involves an app's data, the relevant file under your home directory usually +tells the story faster than anything else. + +## What to expect + +This is a small project. Issues are read, but there is no response-time +commitment and no support contract. A clear report that someone can reproduce +is far more likely to be fixed than a fast one that nobody can. diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 00000000..25fc1167 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,29 @@ +name: Set up Node and pnpm +description: > + Checks out nothing and installs nothing on its own — it wires up pnpm, Node + from .nvmrc, and the pnpm store cache, which every JavaScript job needs + identically. Kept as one action so the Node version and the cache key are + defined once; a workflow that pins its own drifts silently and only shows up + as a cache that never hits. + +inputs: + install: + description: Run `pnpm install --frozen-lockfile` after setting up. + required: false + default: 'true' + +runs: + using: composite + steps: + # Third-party actions are pinned to a commit, not a tag: a tag can be + # moved to point at different code, and a workflow with write access + # would run it without anything changing here. Dependabot updates these + # monthly and keeps the version comment beside them current. + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - if: inputs.install == 'true' + run: pnpm install --frozen-lockfile + shell: bash diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..8a952798 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,23 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + groups: + react: + patterns: ["react", "react-dom", "@types/react*"] + tauri: + patterns: ["@tauri-apps/*"] + three: + patterns: ["three", "@types/three", "@react-three/*"] + tooling: + patterns: ["vite", "@vitejs/*", "vitest", "@biomejs/*", "turbo", "typescript", "tailwindcss", "@tailwindcss/*"] + - package-ecosystem: cargo + directory: "/" + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: monthly diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000..26339c5a --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,52 @@ +# Labels a pull request by what it touches. The point in a monorepo is that +# "which layer does this change" is the first question a reviewer asks, and the +# dependency direction (tokens <- ui <- kernel <- apps <- shell <- hosts) makes +# the answer matter: a change to a lower layer can move everything above it. + +'area: tokens': + - changed-files: + - any-glob-to-any-file: 'packages/tokens/**' + +'area: ui': + - changed-files: + - any-glob-to-any-file: 'packages/ui/**' + +'area: kernel': + - changed-files: + - any-glob-to-any-file: ['packages/kernel/**', 'packages/vfs/**', 'packages/platform/**'] + +'area: apps': + - changed-files: + - any-glob-to-any-file: 'packages/apps/**' + +'area: shell': + - changed-files: + - any-glob-to-any-file: 'packages/shell/**' + +'area: web': + - changed-files: + - any-glob-to-any-file: 'apps/web/**' + +'area: landing': + - changed-files: + - any-glob-to-any-file: 'apps/landing/**' + +'area: desktop': + - changed-files: + - any-glob-to-any-file: ['apps/desktop/**', 'crates/**', 'Cargo.toml', 'Cargo.lock'] + +'area: rust': + - changed-files: + - any-glob-to-any-file: ['crates/**', '**/*.rs'] + +'area: ci': + - changed-files: + - any-glob-to-any-file: ['.github/**', 'turbo.json', 'scripts/**'] + +'area: docs': + - changed-files: + - any-glob-to-any-file: ['**/*.md', 'docs/**'] + +dependencies: + - changed-files: + - any-glob-to-any-file: ['pnpm-lock.yaml', 'package.json', '**/package.json'] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..8930b76c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,113 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +# Every job here only reads the repository and uploads artefacts, so the token +# gets read access and nothing else. Without this block it inherits whatever +# the repository's default is, which on many repositories is write. +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + TURBO_TELEMETRY_DISABLED: 1 + DO_NOT_TRACK: 1 + +jobs: + web: + name: Lint · typecheck · test · build + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + # Catches module names that differ only by case before the macOS and + # Windows builds do, since those are the only jobs that would notice. + - run: pnpm check:portability + # The landing page names every built-in app. Nothing else would notice + # if it named one the OS does not ship, or missed one it does. + - run: pnpm check:apps + - run: pnpm check:i18n + - run: pnpm lint + - run: pnpm typecheck + - run: pnpm test -- --run + - run: pnpm build + - uses: actions/upload-artifact@v4 + with: + name: web-dist + path: apps/web/dist + retention-days: 7 + - uses: actions/upload-artifact@v4 + with: + name: landing-dist + path: apps/landing/dist + retention-days: 7 + + e2e: + name: End-to-end (Chromium) + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: web + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - run: pnpm --filter @lumen/web exec playwright install --with-deps chromium + - run: pnpm --filter @lumen/web build + - run: pnpm --filter @lumen/web test:e2e + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: apps/web/playwright-report + retention-days: 7 + + rust: + name: Rust · fmt · clippy · test + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + - name: Tauri Linux prerequisites + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \ + libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev + # Pinned to a commit rather than to @stable, which is a branch whose + # action.yml defaults `toolchain` to stable. Pinning loses that default, + # so the toolchain is named here instead — same behaviour, and a tag + # that cannot be moved under us. + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 + with: + toolchain: stable + components: rustfmt, clippy + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: ". -> target" + - run: cargo fmt --all -- --check + - run: cargo clippy --workspace --all-targets -- -D warnings + - run: cargo test --workspace + + deslop: + name: Design slop scan + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + - name: kill-ai-slop + run: | + node .claude/skills/kill-ai-slop/scripts/scan.mjs . \ + --exclude=node_modules --exclude=dist --exclude=target --exclude=.claude \ + --exclude=pnpm-lock.yaml | tee deslop.txt + - uses: actions/upload-artifact@v4 + with: + name: deslop-report + path: deslop.txt diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..87e919dc --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,50 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + schedule: + # Weekly, so a vulnerability disclosed after a change still surfaces + # against code nobody has touched since. + - cron: '17 4 * * 1' + +permissions: + contents: read + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze ${{ matrix.language }} + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + security-events: write + actions: read + strategy: + fail-fast: false + matrix: + # Rust is not analysed here: CodeQL's Rust support is still in preview, + # and `cargo clippy -D warnings` plus the crate's own tests already run + # on every change. Adding a preview analyser would produce noise, not + # findings. + language: [javascript-typescript, actions] + steps: + - uses: actions/checkout@v4 + + - uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # security-and-quality is the wider pack; the extra maintainability + # rules are worth reading on a codebase this young. + queries: security-and-quality + + # No build step: the JavaScript and Actions analyses read source + # directly, and building would only add a chance to fail for reasons + # unrelated to what is being scanned. + - uses: github/codeql-action/analyze@v3 + with: + category: /language:${{ matrix.language }} diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml new file mode 100644 index 00000000..d1962e66 --- /dev/null +++ b/.github/workflows/desktop.yml @@ -0,0 +1,129 @@ +name: Desktop + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + paths: + - "apps/desktop/**" + - "crates/**" + - "packages/**" + - "Cargo.*" + - "pnpm-lock.yaml" + - ".github/workflows/desktop.yml" + workflow_dispatch: + +concurrency: + group: desktop-${{ github.ref }} + cancel-in-progress: true + +# Building needs nothing but the checkout; only the release job writes, and it +# asks for that itself. +permissions: + contents: read + +env: + TURBO_TELEMETRY_DISABLED: 1 + +jobs: + build: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + # One platform failing must not cancel the others: when a build breaks it + # is usually platform-specific, and hiding the other two turns one + # failure into three unknowns. + fail-fast: false + matrix: + include: + - name: Windows (x64) + runner: windows-latest + target: x86_64-pc-windows-msvc + artifact: lumen-os-windows-x64 + paths: | + target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe + target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi + # Apple silicon runner. An Intel build would need its own matrix + # entry with x86_64-apple-darwin; a universal binary is possible but + # doubles the build and is not worth it until someone asks. + - name: macOS (Apple silicon) + runner: macos-latest + target: aarch64-apple-darwin + artifact: lumen-os-macos-arm64 + paths: | + target/aarch64-apple-darwin/release/bundle/dmg/*.dmg + # Ubuntu 22.04 rather than latest, deliberately: the AppImage is only + # as portable as the glibc it was linked against, so building on the + # oldest runner still supported by the actions widens the range of + # distributions that can run it. 22.04 ships glibc 2.35, and it is + # also the last image carrying webkit2gtk-4.1 without extra work. + - name: Linux (x64) + runner: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + artifact: lumen-os-linux-x64 + paths: | + target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb + target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm + target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage + steps: + - uses: actions/checkout@v4 + + - name: Linux prerequisites + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \ + libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev + + - uses: ./.github/actions/setup + with: + install: 'false' + + # See ci.yml: pinning loses @stable's default, so it is named here. + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 + with: + toolchain: stable + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: ". -> target" + # Without this the three platforms share one key and evict each + # other's artefacts on every run. + key: ${{ matrix.target }} + + - run: pnpm install --frozen-lockfile + + - name: Build Tauri bundles + run: pnpm --filter @lumen/desktop tauri build --target ${{ matrix.target }} + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: ${{ matrix.paths }} + if-no-files-found: error + retention-days: 14 + + release: + name: Attach to release + if: startsWith(github.ref, 'refs/tags/v') + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 + with: + files: | + dist/**/*.exe + dist/**/*.msi + dist/**/*.dmg + dist/**/*.deb + dist/**/*.rpm + dist/**/*.AppImage + generate_release_notes: true diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 00000000..8e188874 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,27 @@ +name: Label + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +# pull_request_target runs against the base repository with a token that can +# write, so it must be given the narrowest set that does the job and must never +# check out or execute the pull request's code. +permissions: + contents: read + pull-requests: write + +concurrency: + group: label-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + label: + name: By touched area + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/labeler@v5 + with: + configuration-path: .github/labeler.yml + sync-labels: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..282ea71c --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +# dependencies +node_modules/ +.pnp +.pnp.js + +# build output +dist/ +dist-ssr/ +build/ +out/ +.next/ +.turbo/ +*.tsbuildinfo +coverage/ +playwright-report/ +test-results/ + +# rust +target/ +apps/desktop/src-tauri/gen/schemas/ + +# vercel +.vercel/ + +# env +.env +.env.* +!.env.example + +# editor / os +.DS_Store +Thumbs.db +.idea/ +*.swp +*.swo +.vscode/* +!.vscode/settings.json +!.vscode/extensions.json +!.vscode/launch.json + +# logs +*.log +npm-debug.log* +pnpm-debug.log* + +# Scratch probes that review agents write to inspect real output. They import +# node:fs and hardcode a scratchpad path, so they are never part of the suite. +**/__probe.test.* + +# Throwaway end-to-end probes. Diagnosing a defect in this repo means opening +# a real browser and measuring, and the spec that does the measuring is +# written to be deleted — `zz-` marks one so it never reaches a commit and +# never sorts above a real spec in the runner's output. +apps/web/e2e/zz-*.spec.ts +apps/web/zz-*.pw.config.ts +# and whatever a probe writes beside itself — screenshots, logs, dumps. +apps/web/zz-* diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 00000000..9ef41ae4 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +pnpm commitlint --edit "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..717e4537 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,11 @@ +pnpm lint-staged + +# Cheap, and it catches a class of breakage that is invisible on Linux: two +# modules in one directory whose names differ only by case resolve +# ambiguously on macOS and Windows. It has already cost two red builds, both +# times because it was only checked in CI. +pnpm check:portability + +# The landing page's app list has to be the one the OS ships. +pnpm check:apps +pnpm check:i18n diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..d3e41e1e --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +auto-install-peers=true +strict-peer-dependencies=false +prefer-workspace-packages=true +link-workspace-packages=true diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..1c53e789 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "biomejs.biome", + "rust-lang.rust-analyzer", + "tauri-apps.tauri-vscode", + "bradlc.vscode-tailwindcss", + "vitest.explorer" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..739e7f4c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,16 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "biomejs.biome", + "editor.codeActionsOnSave": { + "source.organizeImports.biome": "explicit", + "source.fixAll.biome": "explicit" + }, + "[rust]": { + "editor.defaultFormatter": "rust-lang.rust-analyzer" + }, + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true, + "files.eol": "\n", + "rust-analyzer.linkedProjects": ["./Cargo.toml"], + "tailwindCSS.experimental.classRegex": [["cx\\(([^)]*)\\)", "'([^']*)'"]] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..097656cb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,14 @@ +# Agent instructions + +Tool-agnostic instructions for any coding agent (Codex, Cursor, Copilot, +Gemini, Claude…) working in this repository. `CLAUDE.md` is the canonical +version; this file mirrors it. + +- Read `ARCHITECTURE.md` before touching `packages/kernel`, `packages/vfs`, or + `crates/`. +- Run `pnpm check` before proposing a change. Rust changes also run + `cargo clippy --workspace --all-targets -- -D warnings` and `cargo test --workspace`. +- Follow the design rules in `CLAUDE.md` and run `pnpm deslop` on UI work. +- Follow `AI_USAGE_POLICY.md`: draft pull requests only, never merge, never + publish, disclose AI authorship in commit trailers. +- Role definitions live in `.agents/`. diff --git a/AI_USAGE_POLICY.md b/AI_USAGE_POLICY.md new file mode 100644 index 00000000..41167aa8 --- /dev/null +++ b/AI_USAGE_POLICY.md @@ -0,0 +1,76 @@ +# AI usage policy + +Lumen OS is built with AI coding agents in the loop. This document sets the +rules for how AI assistance is used in this repository, so contributors and +users know what to expect. + +## 1. Scope + +This policy covers every AI-assisted contribution to the repository: code, +documentation, tests, design assets, commit messages, review comments, and +issue triage. It applies to maintainers and outside contributors alike. + +## 2. Principles + +1. **A person is accountable for every change.** An AI agent may write the + diff, but the person who opens the pull request owns it, has read it, and + can explain it. +2. **AI output is a draft, not a decision.** Architecture, security posture, + licensing, and user-facing behaviour are decided by humans and recorded in + `ARCHITECTURE.md`, ADRs, or the pull request itself. +3. **Verification is not optional.** AI-authored code ships only after the same + checks as any other code: `pnpm check` (lint, typecheck, unit tests, + build), `cargo clippy`, `cargo test`, and, for UI, a visual review of the + running app. +4. **No secrets, no private data.** Never paste credentials, tokens, customer + data, or unreleased third-party material into an agent session or prompt. +5. **Respect licences.** AI-generated code must not reproduce licensed code + verbatim. If an agent pulls in a dependency or a vendored file, its licence + is recorded (see `.claude/skills/kill-ai-slop/LICENSE` for an example). + +## 3. Disclosure + +- Commits authored or co-authored by an agent carry a `Co-Authored-By` trailer + naming the agent. Do not strip it. +- Pull requests that are substantially AI-generated say so in the description. +- Review comments posted by an agent end with an attribution line. + +## 4. What agents may do without asking + +- Create branches, commits, and **draft** pull requests. +- Run any command inside the repository sandbox: install dependencies, build, + test, lint, format, run dev servers, take screenshots. +- Refactor within the scope of the task they were given. +- Fix CI failures on pull requests they opened. + +## 5. What requires a human decision + +- Merging to `main`. +- Publishing releases, tags, or packages. +- Production deployments outside the preview flow (Vercel previews are fine; + promoting to production is a human action). +- Changing licences, this policy, the code of conduct, or security settings. +- Adding telemetry, analytics, or any network call that sends user data. +- Deleting user data or changing the on-disk layout of the Lumen OS home + directory in a way that is not backwards compatible. + +## 6. Design quality + +AI-generated interfaces tend toward a recognisable set of defaults. Every UI +change is checked against the vendored `kill-ai-slop` skill +(`pnpm deslop`) and against the design rules in `CLAUDE.md`. A change that +fails that review is not finished. + +## 7. Data used by agents + +Agents operating on this repository read the repository and public +documentation. They do not have access to user machines, user files, or any +Lumen OS home directory. The Tauri build never sends data to an AI service; the +OS itself contains no AI features unless a user installs one explicitly. + +## 8. Reporting + +If you believe AI-assisted output in this repository violates this policy, +another project's licence, or someone's rights, open an issue or email +**lumenpearson@gmail.com**. Reports are handled like security reports (see +`SECURITY.md`). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..f42a9848 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,111 @@ +# Architecture + +Lumen OS is a desktop environment written in TypeScript and React 19. The same +code runs in a browser tab and inside a Tauri 2 window on Windows, macOS and +Linux. This +document explains the layers, the data flow, and the reasons behind the main +decisions. + +## Layers + +``` +┌──────────────────────────────────────────────────────────────┐ +│ apps/desktop (Tauri) apps/web (Vite SPA) │ hosts +├──────────────────────────────────────────────────────────────┤ +│ packages/shell boot · OOBE · lock · desktop · menubar · │ desktop +│ taskbar · start · windows · cursor · … │ environment +├──────────────────────────────────────────────────────────────┤ +│ packages/apps files · settings · terminal · office · … │ programs +├──────────────────────────────────────────────────────────────┤ +│ packages/kernel processes · windows · registry · settings · │ core +│ events · notifications · clipboard · users │ +├───────────────────────┬──────────────────────────────────────┤ +│ packages/vfs │ packages/platform │ I/O +│ paths · adapters │ web (OPFS/IDB) · tauri (invoke) │ +├───────────────────────┴──────────────────────────────────────┤ +│ packages/ui (atoms → molecules → organisms → templates) │ presentation +│ packages/tokens (colour, type, space, motion) │ +└──────────────────────────────────────────────────────────────┘ + │ tauri commands +┌──────────────────────────────────────────────────────────────┐ +│ crates/lumen-kernel (Rust) sandboxed fs · sysinfo · config │ native +└──────────────────────────────────────────────────────────────┘ +``` + +## Kernel + +The kernel is a set of Zustand stores plus a typed event bus. It has no DOM +dependency and is fully unit-tested. + +- **Process table.** Launching an app creates a process (`pid`, `appId`, + `startedAt`, `args`). Killing a process closes its windows and fires + `process:exit`. The Task Manager reads this table. +- **Window manager.** Windows belong to processes. State: bounds, z-order, + minimised/maximised/fullscreen, snap side, focus. Operations are pure + functions on the store; the shell renders them. Bounds are clamped to the + work area on every viewport change so the system survives any resolution. +- **App registry.** Built-in apps register an `AppDefinition` (id, name, + icon, `component`, default window size, file associations, menus). User + pseudo-programs are `.app` JSON manifests in `/Applications` that either + alias a built-in app or embed an HTML document rendered in a sandboxed + iframe. +- **Settings.** A single typed settings object persisted to + `/System/settings.json`. Appearance, desktop, lock screen, display scale, + accessibility, region. +- **Users and lock screen.** One local user with a password hashed with + PBKDF2-SHA256 (Web Crypto) and a recovery key shown once at setup. The + recovery flow verifies the key and lets the user set a new password, or + wipes the home directory to factory state. +- **Notifications, clipboard, session log.** Small stores; every app can post + a notification, and the Console app tails the session log. + +## Virtual file system + +`packages/vfs` defines `VfsAdapter` (`stat`, `readDir`, `readFile`, +`writeFile`, `mkdir`, `remove`, `rename`, `copy`) and path helpers. Two +adapters ship: + +- **OPFS** for the web build (falls back to IndexedDB when OPFS is not + available, e.g. Firefox private windows). +- **Tauri** for the desktop build: every call is a `invoke` into the Rust + crate, which resolves the path inside the configured home directory and + refuses anything that escapes it. + +On first boot the kernel seeds `/Users/` (Desktop, Documents, Downloads, +Pictures, Music), `/Applications`, `/System`, and `/Trash`. + +## Shell + +The shell is a state machine: `boot → setup | lock → desktop → (sleep | lock | +shutdown)`. Screens are lazy-loaded. The desktop layer holds the wallpaper, +icons, the top menubar (global menu + status items), the taskbar with the +Start menu, the window layer, and overlays (Spotlight, Control Center, +Notification Center, Mission Control, the custom cursor, the screensaver). + +The custom cursor hides the native cursor on the OS root and draws its own, +following the pointer with `requestAnimationFrame` and switching shape based +on the element under the pointer (`data-cursor` attributes and computed +`cursor` styles). It steps aside when the pointer leaves the window. + +## Hosts + +- **apps/web** builds the shell with Vite and deploys as a static SPA. A + service worker caches assets so the OS opens offline after the first visit. +- **apps/desktop** wraps the same bundle in a Tauri window without native + decorations. The Rust side exposes: `fs_*` commands over the sandbox, + `system_info`, `system_processes`, `config_get/set`, and window helpers. + The home directory defaults to `%LOCALAPPDATA%\LumenOS\home` on Windows and + can be moved from Settings → Storage. + +## Why these choices + +- **Zustand over Redux/Context.** Window dragging updates at 60–120 Hz; store + selectors keep re-renders scoped to the window being dragged. +- **Tailwind 4 with CSS tokens.** One `@theme` in `packages/tokens` feeds both + the utility classes and plain CSS. The theme is the single source of truth + for colour, radius, and motion, which is what keeps the system visually + consistent. +- **Vite for every app, no SSR.** The OS is a client application; the + landing page has nothing to render on a server. One build tool, one config. +- **A Rust crate separate from the Tauri binary.** `lumen-kernel` compiles and + tests on Linux CI without a WebView; the Tauri binary only wires commands. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..ac3ae11b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,84 @@ +# Lumen OS — agent guide + +This file is read by Claude Code (and mirrored for other agents in `AGENTS.md`). +It describes how to work in this repository. Read `ARCHITECTURE.md` for the +system design and `CONTRIBUTING.md` for the workflow. + +## What this is + +A desktop operating environment in TypeScript + React 19. Same code runs in a +browser (`apps/web`) and inside Tauri 2 on Windows, macOS and Linux +(`apps/desktop`). The Rust +crate `crates/lumen-kernel` provides sandboxed file access and system +information for the desktop build. A landing site lives in `apps/landing`. + +## Commands + +```bash +pnpm install # deps + husky hooks +pnpm dev:web # http://localhost:5173 +pnpm dev:landing # http://localhost:5174 +pnpm dev:desktop # tauri dev +pnpm check # lint + typecheck + test + build (turbo) +pnpm lint:fix # biome --write +pnpm test # vitest across packages +pnpm deslop # kill-ai-slop scan +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +``` + +Run `pnpm --filter + + diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 00000000..d3d809d4 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,36 @@ +{ + "name": "@lumen/desktop", + "version": "0.1.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite --port 5174 --strictPort", + "build": "vite build", + "typecheck": "tsc -p tsconfig.json", + "lint": "biome check .", + "tauri": "tauri" + }, + "dependencies": { + "@lumen/apps": "workspace:*", + "@lumen/kernel": "workspace:*", + "@lumen/platform": "workspace:*", + "@lumen/shell": "workspace:*", + "@lumen/tokens": "workspace:*", + "@lumen/ui": "workspace:*", + "@tauri-apps/api": "^2.11.1", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@lumen/config-typescript": "workspace:*", + "@tailwindcss/vite": "^4.3.3", + "@tauri-apps/cli": "^2.11.4", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "tailwindcss": "^4.3.3", + "typescript": "5.9.3", + "vite": "^8.2.2" + } +} diff --git a/apps/desktop/public/favicon.svg b/apps/desktop/public/favicon.svg new file mode 100644 index 00000000..5a90f7b6 --- /dev/null +++ b/apps/desktop/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop/public/icons/icon-512.png b/apps/desktop/public/icons/icon-512.png new file mode 100644 index 00000000..8c656e56 Binary files /dev/null and b/apps/desktop/public/icons/icon-512.png differ diff --git a/apps/desktop/src-tauri/.gitignore b/apps/desktop/src-tauri/.gitignore new file mode 100644 index 00000000..2e3f5fc2 --- /dev/null +++ b/apps/desktop/src-tauri/.gitignore @@ -0,0 +1,2 @@ +target/ +gen/schemas/ diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml new file mode 100644 index 00000000..a1d4f677 --- /dev/null +++ b/apps/desktop/src-tauri/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "lumen-desktop" +description = "Lumen OS desktop host (Tauri 2)" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lib] +name = "lumen_desktop_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { workspace = true } + +[dependencies] +tauri = { workspace = true } +tauri-plugin-opener = { workspace = true } +tauri-plugin-dialog = { workspace = true } +tauri-plugin-window-state = { workspace = true } +tauri-plugin-process = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +percent-encoding = { workspace = true } +lumen-kernel = { path = "../../../crates/lumen-kernel" } diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs new file mode 100644 index 00000000..d860e1e6 --- /dev/null +++ b/apps/desktop/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json new file mode 100644 index 00000000..588964a2 --- /dev/null +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -0,0 +1,24 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Main window: window controls, external links in the browser, the folder picker for the home directory, window state, and exit.", + "windows": ["main"], + "permissions": [ + "core:default", + "core:window:allow-minimize", + "core:window:allow-toggle-maximize", + "core:window:allow-close", + "core:window:allow-set-fullscreen", + "core:window:allow-is-fullscreen", + "core:window:allow-set-title", + "core:window:allow-set-always-on-top", + "core:window:allow-start-dragging", + { + "identifier": "opener:allow-open-url", + "allow": [{ "url": "https://*" }, { "url": "http://*" }, { "url": "mailto:*" }] + }, + "dialog:allow-open", + "window-state:default", + "process:allow-exit" + ] +} diff --git a/apps/desktop/src-tauri/icons/128x128.png b/apps/desktop/src-tauri/icons/128x128.png new file mode 100644 index 00000000..b02057ee Binary files /dev/null and b/apps/desktop/src-tauri/icons/128x128.png differ diff --git a/apps/desktop/src-tauri/icons/128x128@2x.png b/apps/desktop/src-tauri/icons/128x128@2x.png new file mode 100644 index 00000000..c3e24244 Binary files /dev/null and b/apps/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/apps/desktop/src-tauri/icons/32x32.png b/apps/desktop/src-tauri/icons/32x32.png new file mode 100644 index 00000000..ae524ebc Binary files /dev/null and b/apps/desktop/src-tauri/icons/32x32.png differ diff --git a/apps/desktop/src-tauri/icons/64x64.png b/apps/desktop/src-tauri/icons/64x64.png new file mode 100644 index 00000000..9d858008 Binary files /dev/null and b/apps/desktop/src-tauri/icons/64x64.png differ diff --git a/apps/desktop/src-tauri/icons/Square107x107Logo.png b/apps/desktop/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 00000000..32a8d595 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square142x142Logo.png b/apps/desktop/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 00000000..782e919f Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square150x150Logo.png b/apps/desktop/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 00000000..c8101557 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square284x284Logo.png b/apps/desktop/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 00000000..261e5d64 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square30x30Logo.png b/apps/desktop/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 00000000..844b823c Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square310x310Logo.png b/apps/desktop/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 00000000..7490fb2e Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square44x44Logo.png b/apps/desktop/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 00000000..feb05dac Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square71x71Logo.png b/apps/desktop/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 00000000..628e4497 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square89x89Logo.png b/apps/desktop/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 00000000..6e19258b Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/apps/desktop/src-tauri/icons/StoreLogo.png b/apps/desktop/src-tauri/icons/StoreLogo.png new file mode 100644 index 00000000..3abab290 Binary files /dev/null and b/apps/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/apps/desktop/src-tauri/icons/icon.icns b/apps/desktop/src-tauri/icons/icon.icns new file mode 100644 index 00000000..791d3b5a Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon.icns differ diff --git a/apps/desktop/src-tauri/icons/icon.ico b/apps/desktop/src-tauri/icons/icon.ico new file mode 100644 index 00000000..79bf4bcb Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon.ico differ diff --git a/apps/desktop/src-tauri/icons/icon.png b/apps/desktop/src-tauri/icons/icon.png new file mode 100644 index 00000000..c5d90bb1 Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon.png differ diff --git a/apps/desktop/src-tauri/src/commands/config.rs b/apps/desktop/src-tauri/src/commands/config.rs new file mode 100644 index 00000000..89f507c5 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/config.rs @@ -0,0 +1,81 @@ +//! `config_*` commands. Changing `homeDir` re-points the sandbox at the new +//! directory (created if missing) without moving any files. + +use std::sync::Mutex; + +use lumen_kernel::{ConfigPatch, HostConfig, KernelError, Sandbox}; +use tauri::{AppHandle, State}; +use tauri_plugin_dialog::DialogExt; + +use crate::lock; + +type Config<'a> = State<'a, Mutex>; +type Sb<'a> = State<'a, Mutex>; + +#[tauri::command] +pub async fn config_get(config: Config<'_>) -> Result { + Ok(lock(&config)?.clone()) +} + +#[tauri::command] +pub async fn config_set( + patch: ConfigPatch, + config: Config<'_>, + sandbox: Sb<'_>, +) -> Result { + apply(patch, &config, &sandbox) +} + +/// Native folder picker. Returns the new home directory, or `None` when the +/// user cancels. The config lock is not held while the dialog is open. +#[tauri::command] +pub async fn config_pick_home_dir( + app: AppHandle, + config: Config<'_>, + sandbox: Sb<'_>, +) -> Result, KernelError> { + let current = lock(&config)?.home_path(); + let picked = app + .dialog() + .file() + .set_title("Choose the Lumen OS home folder") + .set_directory(¤t) + .blocking_pick_folder(); + let Some(picked) = picked else { + return Ok(None); + }; + let path = picked.into_path().map_err(|err| { + KernelError::host(format!("folder picker returned an unusable path: {err}")) + })?; + let home = path + .to_str() + .ok_or_else(|| { + KernelError::invalid(path.to_string_lossy(), "folder path is not valid UTF-8") + })? + .to_owned(); + let patch = ConfigPatch { + home_dir: Some(home), + ..ConfigPatch::default() + }; + let updated = apply(patch, &config, &sandbox)?; + Ok(Some(updated.home_dir)) +} + +/// Merge, re-point the sandbox if the home moved, persist, then publish. +/// Nothing is committed to the managed state until every step succeeded. +fn apply( + patch: ConfigPatch, + config: &Mutex, + sandbox: &Mutex, +) -> Result { + let mut current = lock(config)?; + let mut next = current.clone(); + let home_changed = next.merge(patch)?; + if home_changed { + let moved = Sandbox::new(next.home_path())?; + *lock(sandbox)? = moved; + } + next.save()?; + *current = next.clone(); + Ok(next) +} diff --git a/apps/desktop/src-tauri/src/commands/fs.rs b/apps/desktop/src-tauri/src/commands/fs.rs new file mode 100644 index 00000000..4dc01eb9 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/fs.rs @@ -0,0 +1,163 @@ +//! `fs_*` commands over the sandbox. `fs_read_file` answers with raw bytes +//! (an `ArrayBuffer` in JS) and `fs_write_file` reads the raw request body, +//! with the path and options carried in headers, so binary data never goes +//! through a JSON number array. + +use std::borrow::Cow; +use std::sync::Mutex; + +use lumen_kernel::{fs, FileStat, KernelError, Sandbox, Usage}; +use percent_encoding::percent_decode_str; +use tauri::ipc::{InvokeBody, Request, Response}; +use tauri::State; + +use crate::lock; + +type Sb<'a> = State<'a, Mutex>; + +const PATH_HEADER: &str = "x-lumen-path"; +const RECURSIVE_HEADER: &str = "x-lumen-recursive"; + +#[tauri::command] +pub async fn fs_stat(path: String, sandbox: Sb<'_>) -> Result { + let sb = lock(&sandbox)?; + fs::stat(&sb, &path) +} + +#[tauri::command] +pub async fn fs_read_dir(path: String, sandbox: Sb<'_>) -> Result, KernelError> { + let sb = lock(&sandbox)?; + fs::read_dir(&sb, &path) +} + +#[tauri::command] +pub async fn fs_read_file(path: String, sandbox: Sb<'_>) -> Result { + let sb = lock(&sandbox)?; + let bytes = fs::read_file(&sb, &path)?; + Ok(Response::new(bytes)) +} + +#[tauri::command] +pub async fn fs_write_file(request: Request<'_>, sandbox: Sb<'_>) -> Result<(), KernelError> { + let encoded = header(&request, PATH_HEADER)? + .ok_or_else(|| KernelError::invalid("", format!("{PATH_HEADER} header is missing")))?; + let path = decode_path(&encoded)?; + let recursive = header(&request, RECURSIVE_HEADER)?.as_deref() == Some("1"); + let data = body_bytes(request.body(), &path)?; + let sb = lock(&sandbox)?; + fs::write_file(&sb, &path, &data, recursive) +} + +#[tauri::command] +pub async fn fs_mkdir(path: String, recursive: bool, sandbox: Sb<'_>) -> Result<(), KernelError> { + let sb = lock(&sandbox)?; + fs::mkdir(&sb, &path, recursive) +} + +#[tauri::command] +pub async fn fs_remove(path: String, recursive: bool, sandbox: Sb<'_>) -> Result<(), KernelError> { + let sb = lock(&sandbox)?; + fs::remove(&sb, &path, recursive) +} + +#[tauri::command] +pub async fn fs_rename(path: String, to: String, sandbox: Sb<'_>) -> Result<(), KernelError> { + let sb = lock(&sandbox)?; + fs::rename(&sb, &path, &to) +} + +#[tauri::command] +pub async fn fs_copy_file(path: String, to: String, sandbox: Sb<'_>) -> Result<(), KernelError> { + let sb = lock(&sandbox)?; + fs::copy_file(&sb, &path, &to) +} + +#[tauri::command] +pub async fn fs_usage(path: String, sandbox: Sb<'_>) -> Result { + let sb = lock(&sandbox)?; + fs::usage(&sb, &path) +} + +fn header(request: &Request<'_>, name: &str) -> Result, KernelError> { + match request.headers().get(name) { + None => Ok(None), + Some(value) => value + .to_str() + .map(|s| Some(s.to_owned())) + .map_err(|_| KernelError::invalid("", format!("{name} header is not ASCII"))), + } +} + +/// Raw bytes as sent by `invoke(cmd, Uint8Array)`. A JSON array of byte +/// values is accepted too, for callers that cannot send a binary body. +fn body_bytes<'a>(body: &'a InvokeBody, path: &str) -> Result, KernelError> { + match body { + InvokeBody::Raw(bytes) => Ok(Cow::Borrowed(bytes)), + InvokeBody::Json(serde_json::Value::Array(items)) => items + .iter() + .map(|v| { + v.as_u64() + .and_then(|n| u8::try_from(n).ok()) + .ok_or_else(|| KernelError::invalid(path, "body array must hold bytes")) + }) + .collect::, KernelError>>() + .map(Cow::Owned), + InvokeBody::Json(_) => Err(KernelError::invalid(path, "body must be raw bytes")), + } +} + +/// Undo the `encodeURIComponent` the front end applies to the path header, +/// which is how non-ASCII names survive an HTTP header. +fn decode_path(encoded: &str) -> Result { + percent_decode_str(encoded) + .decode_utf8() + .map(|decoded| decoded.into_owned()) + .map_err(|_| KernelError::invalid(encoded, "path is not valid UTF-8")) +} + +#[cfg(test)] +mod tests { + use super::*; + use lumen_kernel::ErrorCode; + use serde_json::json; + + #[test] + fn decodes_percent_encoded_paths() { + assert_eq!( + decode_path("%2FUsers%2Fme%2Fa.txt").unwrap(), + "/Users/me/a.txt" + ); + assert_eq!( + decode_path("%2FDocuments%2FR%C3%A9sum%C3%A9.pdf").unwrap(), + "/Documents/Résumé.pdf" + ); + assert_eq!(decode_path("%2Fa%20b%20%26%20c").unwrap(), "/a b & c"); + assert_eq!(decode_path("/plain").unwrap(), "/plain"); + assert_eq!(decode_path("%FF%FE").unwrap_err().code, ErrorCode::Einval); + } + + #[test] + fn reads_raw_and_json_array_bodies() { + let raw = InvokeBody::Raw(vec![1, 2, 3]); + assert_eq!(body_bytes(&raw, "/a").unwrap().as_ref(), &[1, 2, 3]); + + let array = InvokeBody::Json(json!([0, 127, 255])); + assert_eq!(body_bytes(&array, "/a").unwrap().as_ref(), &[0, 127, 255]); + + let empty = InvokeBody::Json(json!([])); + assert!(body_bytes(&empty, "/a").unwrap().is_empty()); + } + + #[test] + fn rejects_bodies_that_are_not_bytes() { + for body in [ + InvokeBody::Json(json!({ "data": [1] })), + InvokeBody::Json(json!("text")), + InvokeBody::Json(json!([256])), + InvokeBody::Json(json!([-1])), + InvokeBody::Json(json!([null])), + ] { + assert_eq!(body_bytes(&body, "/a").unwrap_err().code, ErrorCode::Einval); + } + } +} diff --git a/apps/desktop/src-tauri/src/commands/interface.rs b/apps/desktop/src-tauri/src/commands/interface.rs new file mode 100644 index 00000000..f22ccc04 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/interface.rs @@ -0,0 +1,115 @@ +//! `interface_*` commands, and the scheme that serves the interface itself. +//! +//! Lumen's interface is a web bundle. The copy compiled into this binary is +//! the floor; a bundle applied under the data directory takes precedence when +//! there is one, which is what lets the interface be replaced without +//! replacing the executable. +//! +//! Every decision here — which version is live, whether a path stays inside +//! it, whether a version that never reported should be given up — is made in +//! `lumen_kernel::interface`, where it is tested without a WebView. What is +//! left in this file is the wiring: a request in, bytes out. + +use std::sync::Mutex; + +use lumen_kernel::{InterfaceState, InterfaceStore, KernelError}; +use tauri::{Manager, State}; + +use crate::lock; + +/// The version given up at start, if any. Held for the session so the +/// interface can say what happened once it is up to say it. +pub struct RolledBack(pub Mutex>); + +/// The binary's own version, which no patch can move. +pub const HOST_VERSION: &str = env!("CARGO_PKG_VERSION"); + +type Store<'a> = State<'a, Mutex>; + +#[tauri::command] +pub async fn interface_state( + store: Store<'_>, + rolled: State<'_, RolledBack>, +) -> Result { + let from = lock(&rolled.0)?.clone(); + Ok(lock(&store)?.state(HOST_VERSION, from)) +} + +/// The interface reporting that it drew itself. Until this arrives the live +/// version is on probation, and the next start would give it up. +#[tauri::command] +pub async fn interface_ready(store: Store<'_>) -> Result<(), KernelError> { + let store = lock(&store)?; + let Some(pointer) = store.pointer()? else { + // The bundle inside the binary needs no probation: it shipped with + // the executable and there is nothing to fall back to. + return Ok(()); + }; + store.mark_booted(&pointer.version) +} + +/// One media type per extension, because the interface is a fixed set of file +/// kinds and a lookup table is the whole of what is needed. Anything else is +/// served as bytes rather than guessed at. +fn mime_for(path: &str) -> &'static str { + match path.rsplit('.').next().unwrap_or("") { + "html" => "text/html; charset=utf-8", + "js" | "mjs" => "text/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "json" | "webmanifest" => "application/json; charset=utf-8", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + "ico" => "image/x-icon", + "woff2" => "font/woff2", + "woff" => "font/woff", + "wasm" => "application/wasm", + "txt" | "md" => "text/plain; charset=utf-8", + _ => "application/octet-stream", + } +} + +/// The applied bundle first, the one inside the binary second, a 404 third. +/// +/// Nothing here may panic: a request for a path that is in neither place is +/// an ordinary miss, and the window is already open by the time it arrives. +pub fn respond( + app: &tauri::AppHandle, + request: &tauri::http::Request>, +) -> tauri::http::Response> { + let raw = request.uri().path(); + let path = if raw.is_empty() || raw == "/" { + "/index.html" + } else { + raw + }; + + let patched = app.try_state::>().and_then(|store| { + let store = store.lock().ok()?; + let pointer = store.pointer().ok().flatten()?; + let file = store.resolve(&pointer.version, path)?; + std::fs::read(&file).ok() + }); + + let body = match patched { + Some(bytes) => Some(bytes), + None => app + .asset_resolver() + .get(path.to_owned()) + .map(|asset| asset.bytes), + }; + + let build = tauri::http::Response::builder(); + match body { + Some(bytes) => build + .header("Content-Type", mime_for(path)) + // The interface is served from disk on every start, and a cached + // copy of a version that has since been rolled back is exactly + // the state this must never get into. + .header("Cache-Control", "no-store") + .body(bytes), + None => build.status(404).body(Vec::new()), + } + .unwrap_or_else(|_| tauri::http::Response::new(Vec::new())) +} diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs new file mode 100644 index 00000000..677a3355 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -0,0 +1,13 @@ +//! Tauri command wrappers, one module per section of the platform contract +//! in `packages/platform/src/tauri.ts`. Each command parses its arguments, +//! locks the managed state, calls `lumen_kernel` and returns +//! `Result<_, KernelError>`, which Tauri serialises for the front end. +//! Commands are `async` so they run on the runtime's thread pool rather +//! than the UI thread. + +pub mod config; +pub mod fs; +pub mod interface; +pub mod page; +pub mod shell; +pub mod system; diff --git a/apps/desktop/src-tauri/src/commands/page.rs b/apps/desktop/src-tauri/src/commands/page.rs new file mode 100644 index 00000000..3009984d --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/page.rs @@ -0,0 +1,257 @@ +//! A real web view for the browser app. +//! +//! Most of the web cannot be shown in an iframe. A site that sends +//! `X-Frame-Options` or a `frame-ancestors` policy is turned away by the +//! engine before the page ever runs, and no amount of work inside the +//! interface changes that answer. On the desktop there is a way round it that +//! is not a trick: the host puts a second, real web view inside the same +//! window, over the rectangle the browser app has reserved for the page. That +//! view is a browser — every site, its own cookies, its own scripts, its own +//! history. +//! +//! The view is a native surface, so it is drawn above the interface rather +//! than inside it. Everything that follows from that — hiding it when a Lumen +//! window covers the area, moving it when the window moves — is decided in +//! the interface, which knows where its windows are, and carried out through +//! `page_place`. +//! +//! What the view is allowed to be is decided in `lumen_kernel::webview`, +//! which has no window to open and so can be tested. + +use lumen_kernel::{checked_label, checked_url, sane_zoom, KernelError, ViewRect}; +use serde::Serialize; +use tauri::{ + webview::{NewWindowResponse, PageLoadEvent, WebviewBuilder}, + AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, Webview, WebviewUrl, Wry, +}; + +/// The label of the window every page view is a child of, and of the web view +/// the interface itself runs in — the one told about what the page does. +const MAIN: &str = "main"; + +/// What a page view reports back to the interface. +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct PageEvent { + /// The tab's id, as the interface knows it. + id: String, + /// `started`, `loaded`, `title` or `popup`. + kind: &'static str, + url: String, + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, +} + +/// The channel the interface listens on. One event name for every kind of +/// report, because a listener that has to be registered per kind is a +/// listener that will be missing one. +const CHANNEL: &str = "lumen://page"; + +fn tell(app: &AppHandle, event: PageEvent) { + if let Err(err) = app.emit_to(MAIN, CHANNEL, event) { + eprintln!("lumen: cannot report a page view: {err}"); + } +} + +/// The id a label was made from, for reporting back. +fn id_of(label: &str) -> String { + label + .strip_prefix(lumen_kernel::VIEW_PREFIX) + .unwrap_or(label) + .to_string() +} + +fn view(app: &AppHandle, id: &str) -> Result, KernelError> { + let label = checked_label(id)?; + app.get_webview(&label) + .ok_or_else(|| KernelError::host(format!("no page view {label}"))) +} + +/// Open a page view, or point an open one at a new address. +/// +/// Opening is idempotent: the browser app asks for the view it wants on +/// screen and does not track whether the host still has it, because a view +/// can be closed from under it — by a crash inside the view, or by the window +/// going away — and asking again is the cure for both. +#[tauri::command] +pub async fn page_open( + app: AppHandle, + id: String, + url: String, + rect: ViewRect, + visible: bool, +) -> Result<(), KernelError> { + if view(&app, &id).is_ok() { + return place(&app, &id, rect, visible).and_then(|()| navigate(&app, &id, &url)); + } + let label = checked_label(&id)?; + let address = checked_url(&url)?.to_string(); + let rect = rect.sane(); + + let window = app + .get_window(MAIN) + .ok_or_else(|| KernelError::host("the desktop window is not open"))?; + + let parsed = address + .parse() + .map_err(|err| KernelError::invalid(&address, format!("cannot read the address: {err}")))?; + + let reporter = app.clone(); + let popups = app.clone(); + let titles = app.clone(); + let builder = WebviewBuilder::::new(&label, WebviewUrl::External(parsed)) + // The page decides what it draws; Lumen never reads inside it. + .on_page_load(move |webview, payload| { + let kind = match payload.event() { + PageLoadEvent::Started => "started", + PageLoadEvent::Finished => "loaded", + }; + tell( + &reporter, + PageEvent { + id: id_of(webview.label()), + kind, + url: payload.url().to_string(), + title: None, + }, + ); + }) + .on_document_title_changed(move |webview, title| { + let url = webview + .url() + .map(|u| u.to_string()) + .unwrap_or_else(|_| String::new()); + tell( + &titles, + PageEvent { + id: id_of(webview.label()), + kind: "title", + url, + title: Some(title), + }, + ); + }) + // A link that asks for a new window gets one — a Lumen tab. The host + // opening a bare window of its own would put a page outside the + // browser that is supposed to be showing it. + .on_new_window(move |url, _features| { + tell( + &popups, + PageEvent { + id: String::new(), + kind: "popup", + url: url.to_string(), + title: None, + }, + ); + NewWindowResponse::Deny + }) + .zoom_hotkeys_enabled(true) + .focused(false); + + let view = window + .add_child( + builder, + LogicalPosition::new(rect.x, rect.y), + LogicalSize::new(rect.width, rect.height), + ) + .map_err(|err| KernelError::host(format!("cannot open a page view: {err}")))?; + + if !visible { + let _ = view.hide(); + } + Ok(()) +} + +/// Point an open view at another address. +#[tauri::command] +pub async fn page_navigate(app: AppHandle, id: String, url: String) -> Result<(), KernelError> { + navigate(&app, &id, &url) +} + +fn navigate(app: &AppHandle, id: &str, url: &str) -> Result<(), KernelError> { + let address = checked_url(url)?; + let parsed = address + .parse() + .map_err(|err| KernelError::invalid(address, format!("cannot read the address: {err}")))?; + view(app, id)? + .navigate(parsed) + .map_err(|err| KernelError::host(format!("cannot open {address}: {err}"))) +} + +/// Move a view, resize it, and show or hide it — one call, because all three +/// are answers to the same question and the interface asks it on every frame +/// a window is dragged. +#[tauri::command] +pub async fn page_place( + app: AppHandle, + id: String, + rect: ViewRect, + visible: bool, +) -> Result<(), KernelError> { + place(&app, &id, rect, visible) +} + +fn place(app: &AppHandle, id: &str, rect: ViewRect, visible: bool) -> Result<(), KernelError> { + let view = view(app, id)?; + let rect = rect.sane(); + let report = |what: &str, err: tauri::Error| KernelError::host(format!("cannot {what}: {err}")); + if visible { + view.set_position(LogicalPosition::new(rect.x, rect.y)) + .map_err(|err| report("move the page", err))?; + view.set_size(LogicalSize::new(rect.width, rect.height)) + .map_err(|err| report("resize the page", err))?; + view.show().map_err(|err| report("show the page", err)) + } else { + view.hide().map_err(|err| report("hide the page", err)) + } +} + +/// The browser app's zoom, applied to the view rather than to a wrapper the +/// view does not live in. +#[tauri::command] +pub async fn page_zoom(app: AppHandle, id: String, factor: f64) -> Result<(), KernelError> { + view(&app, &id)? + .set_zoom(sane_zoom(factor)) + .map_err(|err| KernelError::host(format!("cannot zoom the page: {err}"))) +} + +/// Ask the page for itself again. +#[tauri::command] +pub async fn page_reload(app: AppHandle, id: String) -> Result<(), KernelError> { + view(&app, &id)? + .eval("location.reload()") + .map_err(|err| KernelError::host(format!("cannot reload the page: {err}"))) +} + +/// Close one view. A tab that is gone leaves nothing behind on screen. +#[tauri::command] +pub async fn page_close(app: AppHandle, id: String) -> Result<(), KernelError> { + let Ok(view) = view(&app, &id) else { + return Ok(()); + }; + view.close() + .map_err(|err| KernelError::host(format!("cannot close the page view: {err}"))) +} + +/// Close every view. The browser app calls this when its window closes, so a +/// view can never outlive the window it was drawn over. +#[tauri::command] +pub async fn page_close_all(app: AppHandle) -> Result<(), KernelError> { + let Some(window) = app.get_window(MAIN) else { + return Ok(()); + }; + for view in window.webviews() { + if view.label().starts_with(lumen_kernel::VIEW_PREFIX) { + let _ = view.close(); + } + } + Ok(()) +} + +/// Whether the host can open a page view at all. The interface asks once and +/// falls back to a framed page when the answer is no. +#[tauri::command] +pub async fn page_supported() -> bool { + true +} diff --git a/apps/desktop/src-tauri/src/commands/shell.rs b/apps/desktop/src-tauri/src/commands/shell.rs new file mode 100644 index 00000000..116b1003 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/shell.rs @@ -0,0 +1,104 @@ +//! Host shell integration: external links, the file manager, and quitting. + +use std::sync::Mutex; + +use lumen_kernel::{KernelError, Sandbox}; +use tauri::{AppHandle, State}; +use tauri_plugin_opener::OpenerExt; + +use crate::lock; + +const ALLOWED_SCHEMES: [&str; 3] = ["http://", "https://", "mailto:"]; + +/// Open a link in the default browser or mail client. Only `http`, `https` +/// and `mailto` are accepted; anything else could start a program. +#[tauri::command] +pub async fn shell_open_external(url: String, app: AppHandle) -> Result<(), KernelError> { + let url = checked_url(&url)?; + app.opener() + .open_url(url, None::<&str>) + .map_err(|err| KernelError::host(format!("cannot open link: {err}"))) +} + +/// The guard for `shell_open_external`: trim, allow only the three safe +/// schemes, and refuse control characters, which some handlers treat as +/// argument separators. +fn checked_url(url: &str) -> Result<&str, KernelError> { + let url = url.trim(); + let lower = url.to_ascii_lowercase(); + if !ALLOWED_SCHEMES + .iter() + .any(|scheme| lower.starts_with(scheme)) + { + return Err(KernelError::invalid( + url, + "only http, https and mailto links can be opened", + )); + } + if url.chars().any(char::is_control) { + return Err(KernelError::invalid( + url, + "link contains control characters", + )); + } + Ok(url) +} + +/// Show the home directory in the host file manager. +#[tauri::command] +pub async fn shell_reveal_home( + app: AppHandle, + sandbox: State<'_, Mutex>, +) -> Result<(), KernelError> { + let root = lock(&sandbox)?.root().to_path_buf(); + let opener = app.opener(); + opener + .open_path(root.to_string_lossy(), None::<&str>) + .or_else(|_| opener.reveal_item_in_dir(&root)) + .map_err(|err| KernelError::host(format!("cannot open the home directory: {err}"))) +} + +#[tauri::command] +pub async fn app_quit(app: AppHandle) -> Result<(), KernelError> { + app.exit(0); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use lumen_kernel::ErrorCode; + + #[test] + fn accepts_web_and_mail_links() { + for url in [ + "https://example.com/a?b=c#d", + "http://localhost:5174/", + "mailto:someone@example.com?subject=Hi", + "HTTPS://EXAMPLE.COM", + " https://example.com ", + ] { + assert_eq!(checked_url(url).unwrap(), url.trim(), "{url}"); + } + } + + #[test] + fn rejects_other_schemes_and_control_characters() { + for url in [ + "file:///C:/Windows/System32", + "javascript:alert(1)", + "data:text/html, + + +
    + + + diff --git a/apps/landing/package.json b/apps/landing/package.json new file mode 100644 index 00000000..1e7b754c --- /dev/null +++ b/apps/landing/package.json @@ -0,0 +1,35 @@ +{ + "name": "@lumen/landing", + "version": "0.1.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite --port 5175 --strictPort", + "build": "vite build", + "preview": "vite preview --port 4175 --strictPort", + "typecheck": "tsc -p tsconfig.json", + "lint": "biome check .", + "test": "vitest run" + }, + "dependencies": { + "@lumen/tokens": "workspace:*", + "@react-three/fiber": "^9.7.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "three": "^0.185.1" + }, + "devDependencies": { + "@lumen/config-typescript": "workspace:*", + "@lumen/config-vitest": "workspace:*", + "@tailwindcss/vite": "^4.3.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@types/three": "^0.185.0", + "@vitejs/plugin-react": "^6.1.1", + "tailwindcss": "^4.3.3", + "typescript": "5.9.3", + "vite": "^8.2.2", + "vitest": "^5.0.0" + } +} diff --git a/apps/landing/public/favicon.svg b/apps/landing/public/favicon.svg new file mode 100644 index 00000000..d2774388 --- /dev/null +++ b/apps/landing/public/favicon.svg @@ -0,0 +1 @@ +Lumen OS diff --git a/apps/landing/public/icon-512.png b/apps/landing/public/icon-512.png new file mode 100644 index 00000000..8c656e56 Binary files /dev/null and b/apps/landing/public/icon-512.png differ diff --git a/apps/landing/public/og.png b/apps/landing/public/og.png new file mode 100644 index 00000000..fd6338aa Binary files /dev/null and b/apps/landing/public/og.png differ diff --git a/apps/landing/src/App.tsx b/apps/landing/src/App.tsx new file mode 100644 index 00000000..87f1aa62 --- /dev/null +++ b/apps/landing/src/App.tsx @@ -0,0 +1,30 @@ +import { Apps } from './components/Apps'; +import { Download } from './components/Download'; +import { Footer } from './components/Footer'; +import { Hero } from './components/Hero'; +import { HowItWorks } from './components/HowItWorks'; +import { Nav } from './components/Nav'; +import { TryIt } from './components/TryIt'; +import { WhatItIs } from './components/WhatItIs'; +import { useTheme } from './lib/useTheme'; + +export function App() { + const [theme, toggleTheme] = useTheme(); + return ( + <> + + Skip to content + +