commit 4305a1082b102d06cee087135cc4bcb4c0180bd6 Author: Axhub Make Date: Wed Jul 29 16:04:39 2026 +0800 初始化项目版本 diff --git a/.agents/skills/algorithmic-art/LICENSE.txt b/.agents/skills/algorithmic-art/LICENSE.txt new file mode 100644 index 0000000..4f881c5 --- /dev/null +++ b/.agents/skills/algorithmic-art/LICENSE.txt @@ -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 2026 Anthropic, PBC. + + 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. \ No newline at end of file diff --git a/.agents/skills/algorithmic-art/SKILL.md b/.agents/skills/algorithmic-art/SKILL.md new file mode 100644 index 0000000..634f6fa --- /dev/null +++ b/.agents/skills/algorithmic-art/SKILL.md @@ -0,0 +1,405 @@ +--- +name: algorithmic-art +description: Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations. +license: Complete terms in LICENSE.txt +--- + +Algorithmic philosophies are computational aesthetic movements that are then expressed through code. Output .md files (philosophy), .html files (interactive viewer), and .js files (generative algorithms). + +This happens in two steps: +1. Algorithmic Philosophy Creation (.md file) +2. Express by creating p5.js generative art (.html + .js files) + +First, undertake this task: + +## ALGORITHMIC PHILOSOPHY CREATION + +To begin, create an ALGORITHMIC PHILOSOPHY (not static images or templates) that will be interpreted through: +- Computational processes, emergent behavior, mathematical beauty +- Seeded randomness, noise fields, organic systems +- Particles, flows, fields, forces +- Parametric variation and controlled chaos + +### THE CRITICAL UNDERSTANDING +- What is received: Some subtle input or instructions by the user to take into account, but use as a foundation; it should not constrain creative freedom. +- What is created: An algorithmic philosophy/generative aesthetic movement. +- What happens next: The same version receives the philosophy and EXPRESSES IT IN CODE - creating p5.js sketches that are 90% algorithmic generation, 10% essential parameters. + +Consider this approach: +- Write a manifesto for a generative art movement +- The next phase involves writing the algorithm that brings it to life + +The philosophy must emphasize: Algorithmic expression. Emergent behavior. Computational beauty. Seeded variation. + +### HOW TO GENERATE AN ALGORITHMIC PHILOSOPHY + +**Name the movement** (1-2 words): "Organic Turbulence" / "Quantum Harmonics" / "Emergent Stillness" + +**Articulate the philosophy** (4-6 paragraphs - concise but complete): + +To capture the ALGORITHMIC essence, express how this philosophy manifests through: +- Computational processes and mathematical relationships? +- Noise functions and randomness patterns? +- Particle behaviors and field dynamics? +- Temporal evolution and system states? +- Parametric variation and emergent complexity? + +**CRITICAL GUIDELINES:** +- **Avoid redundancy**: Each algorithmic aspect should be mentioned once. Avoid repeating concepts about noise theory, particle dynamics, or mathematical principles unless adding new depth. +- **Emphasize craftsmanship REPEATEDLY**: The philosophy MUST stress multiple times that the final algorithm should appear as though it took countless hours to develop, was refined with care, and comes from someone at the absolute top of their field. This framing is essential - repeat phrases like "meticulously crafted algorithm," "the product of deep computational expertise," "painstaking optimization," "master-level implementation." +- **Leave creative space**: Be specific about the algorithmic direction, but concise enough that the next Claude has room to make interpretive implementation choices at an extremely high level of craftsmanship. + +The philosophy must guide the next version to express ideas ALGORITHMICALLY, not through static images. Beauty lives in the process, not the final frame. + +### PHILOSOPHY EXAMPLES + +**"Organic Turbulence"** +Philosophy: Chaos constrained by natural law, order emerging from disorder. +Algorithmic expression: Flow fields driven by layered Perlin noise. Thousands of particles following vector forces, their trails accumulating into organic density maps. Multiple noise octaves create turbulent regions and calm zones. Color emerges from velocity and density - fast particles burn bright, slow ones fade to shadow. The algorithm runs until equilibrium - a meticulously tuned balance where every parameter was refined through countless iterations by a master of computational aesthetics. + +**"Quantum Harmonics"** +Philosophy: Discrete entities exhibiting wave-like interference patterns. +Algorithmic expression: Particles initialized on a grid, each carrying a phase value that evolves through sine waves. When particles are near, their phases interfere - constructive interference creates bright nodes, destructive creates voids. Simple harmonic motion generates complex emergent mandalas. The result of painstaking frequency calibration where every ratio was carefully chosen to produce resonant beauty. + +**"Recursive Whispers"** +Philosophy: Self-similarity across scales, infinite depth in finite space. +Algorithmic expression: Branching structures that subdivide recursively. Each branch slightly randomized but constrained by golden ratios. L-systems or recursive subdivision generate tree-like forms that feel both mathematical and organic. Subtle noise perturbations break perfect symmetry. Line weights diminish with each recursion level. Every branching angle the product of deep mathematical exploration. + +**"Field Dynamics"** +Philosophy: Invisible forces made visible through their effects on matter. +Algorithmic expression: Vector fields constructed from mathematical functions or noise. Particles born at edges, flowing along field lines, dying when they reach equilibrium or boundaries. Multiple fields can attract, repel, or rotate particles. The visualization shows only the traces - ghost-like evidence of invisible forces. A computational dance meticulously choreographed through force balance. + +**"Stochastic Crystallization"** +Philosophy: Random processes crystallizing into ordered structures. +Algorithmic expression: Randomized circle packing or Voronoi tessellation. Start with random points, let them evolve through relaxation algorithms. Cells push apart until equilibrium. Color based on cell size, neighbor count, or distance from center. The organic tiling that emerges feels both random and inevitable. Every seed produces unique crystalline beauty - the mark of a master-level generative algorithm. + +*These are condensed examples. The actual algorithmic philosophy should be 4-6 substantial paragraphs.* + +### ESSENTIAL PRINCIPLES +- **ALGORITHMIC PHILOSOPHY**: Creating a computational worldview to be expressed through code +- **PROCESS OVER PRODUCT**: Always emphasize that beauty emerges from the algorithm's execution - each run is unique +- **PARAMETRIC EXPRESSION**: Ideas communicate through mathematical relationships, forces, behaviors - not static composition +- **ARTISTIC FREEDOM**: The next Claude interprets the philosophy algorithmically - provide creative implementation room +- **PURE GENERATIVE ART**: This is about making LIVING ALGORITHMS, not static images with randomness +- **EXPERT CRAFTSMANSHIP**: Repeatedly emphasize the final algorithm must feel meticulously crafted, refined through countless iterations, the product of deep expertise by someone at the absolute top of their field in computational aesthetics + +**The algorithmic philosophy should be 4-6 paragraphs long.** Fill it with poetic computational philosophy that brings together the intended vision. Avoid repeating the same points. Output this algorithmic philosophy as a .md file. + +--- + +## DEDUCING THE CONCEPTUAL SEED + +**CRITICAL STEP**: Before implementing the algorithm, identify the subtle conceptual thread from the original request. + +**THE ESSENTIAL PRINCIPLE**: +The concept is a **subtle, niche reference embedded within the algorithm itself** - not always literal, always sophisticated. Someone familiar with the subject should feel it intuitively, while others simply experience a masterful generative composition. The algorithmic philosophy provides the computational language. The deduced concept provides the soul - the quiet conceptual DNA woven invisibly into parameters, behaviors, and emergence patterns. + +This is **VERY IMPORTANT**: The reference must be so refined that it enhances the work's depth without announcing itself. Think like a jazz musician quoting another song through algorithmic harmony - only those who know will catch it, but everyone appreciates the generative beauty. + +--- + +## P5.JS IMPLEMENTATION + +With the philosophy AND conceptual framework established, express it through code. Pause to gather thoughts before proceeding. Use only the algorithmic philosophy created and the instructions below. + +### ⚠️ STEP 0: READ THE TEMPLATE FIRST ⚠️ + +**CRITICAL: BEFORE writing any HTML:** + +1. **Read** `templates/viewer.html` using the Read tool +2. **Study** the exact structure, styling, and Anthropic branding +3. **Use that file as the LITERAL STARTING POINT** - not just inspiration +4. **Keep all FIXED sections exactly as shown** (header, sidebar structure, Anthropic colors/fonts, seed controls, action buttons) +5. **Replace only the VARIABLE sections** marked in the file's comments (algorithm, parameters, UI controls for parameters) + +**Avoid:** +- ❌ Creating HTML from scratch +- ❌ Inventing custom styling or color schemes +- ❌ Using system fonts or dark themes +- ❌ Changing the sidebar structure + +**Follow these practices:** +- ✅ Copy the template's exact HTML structure +- ✅ Keep Anthropic branding (Poppins/Lora fonts, light colors, gradient backdrop) +- ✅ Maintain the sidebar layout (Seed → Parameters → Colors? → Actions) +- ✅ Replace only the p5.js algorithm and parameter controls + +The template is the foundation. Build on it, don't rebuild it. + +--- + +To create gallery-quality computational art that lives and breathes, use the algorithmic philosophy as the foundation. + +### TECHNICAL REQUIREMENTS + +**Seeded Randomness (Art Blocks Pattern)**: +```javascript +// ALWAYS use a seed for reproducibility +let seed = 12345; // or hash from user input +randomSeed(seed); +noiseSeed(seed); +``` + +**Parameter Structure - FOLLOW THE PHILOSOPHY**: + +To establish parameters that emerge naturally from the algorithmic philosophy, consider: "What qualities of this system can be adjusted?" + +```javascript +let params = { + seed: 12345, // Always include seed for reproducibility + // colors + // Add parameters that control YOUR algorithm: + // - Quantities (how many?) + // - Scales (how big? how fast?) + // - Probabilities (how likely?) + // - Ratios (what proportions?) + // - Angles (what direction?) + // - Thresholds (when does behavior change?) +}; +``` + +**To design effective parameters, focus on the properties the system needs to be tunable rather than thinking in terms of "pattern types".** + +**Core Algorithm - EXPRESS THE PHILOSOPHY**: + +**CRITICAL**: The algorithmic philosophy should dictate what to build. + +To express the philosophy through code, avoid thinking "which pattern should I use?" and instead think "how to express this philosophy through code?" + +If the philosophy is about **organic emergence**, consider using: +- Elements that accumulate or grow over time +- Random processes constrained by natural rules +- Feedback loops and interactions + +If the philosophy is about **mathematical beauty**, consider using: +- Geometric relationships and ratios +- Trigonometric functions and harmonics +- Precise calculations creating unexpected patterns + +If the philosophy is about **controlled chaos**, consider using: +- Random variation within strict boundaries +- Bifurcation and phase transitions +- Order emerging from disorder + +**The algorithm flows from the philosophy, not from a menu of options.** + +To guide the implementation, let the conceptual essence inform creative and original choices. Build something that expresses the vision for this particular request. + +**Canvas Setup**: Standard p5.js structure: +```javascript +function setup() { + createCanvas(1200, 1200); + // Initialize your system +} + +function draw() { + // Your generative algorithm + // Can be static (noLoop) or animated +} +``` + +### CRAFTSMANSHIP REQUIREMENTS + +**CRITICAL**: To achieve mastery, create algorithms that feel like they emerged through countless iterations by a master generative artist. Tune every parameter carefully. Ensure every pattern emerges with purpose. This is NOT random noise - this is CONTROLLED CHAOS refined through deep expertise. + +- **Balance**: Complexity without visual noise, order without rigidity +- **Color Harmony**: Thoughtful palettes, not random RGB values +- **Composition**: Even in randomness, maintain visual hierarchy and flow +- **Performance**: Smooth execution, optimized for real-time if animated +- **Reproducibility**: Same seed ALWAYS produces identical output + +### OUTPUT FORMAT + +Output: +1. **Algorithmic Philosophy** - As markdown or text explaining the generative aesthetic +2. **Single HTML Artifact** - Self-contained interactive generative art built from `templates/viewer.html` (see STEP 0 and next section) + +The HTML artifact contains everything: p5.js (from CDN), the algorithm, parameter controls, and UI - all in one file that works immediately in claude.ai artifacts or any browser. Start from the template file, not from scratch. + +--- + +## INTERACTIVE ARTIFACT CREATION + +**REMINDER: `templates/viewer.html` should have already been read (see STEP 0). Use that file as the starting point.** + +To allow exploration of the generative art, create a single, self-contained HTML artifact. Ensure this artifact works immediately in claude.ai or any browser - no setup required. Embed everything inline. + +### CRITICAL: WHAT'S FIXED VS VARIABLE + +The `templates/viewer.html` file is the foundation. It contains the exact structure and styling needed. + +**FIXED (always include exactly as shown):** +- Layout structure (header, sidebar, main canvas area) +- Anthropic branding (UI colors, fonts, gradients) +- Seed section in sidebar: + - Seed display + - Previous/Next buttons + - Random button + - Jump to seed input + Go button +- Actions section in sidebar: + - Regenerate button + - Reset button + +**VARIABLE (customize for each artwork):** +- The entire p5.js algorithm (setup/draw/classes) +- The parameters object (define what the art needs) +- The Parameters section in sidebar: + - Number of parameter controls + - Parameter names + - Min/max/step values for sliders + - Control types (sliders, inputs, etc.) +- Colors section (optional): + - Some art needs color pickers + - Some art might use fixed colors + - Some art might be monochrome (no color controls needed) + - Decide based on the art's needs + +**Every artwork should have unique parameters and algorithm!** The fixed parts provide consistent UX - everything else expresses the unique vision. + +### REQUIRED FEATURES + +**1. Parameter Controls** +- Sliders for numeric parameters (particle count, noise scale, speed, etc.) +- Color pickers for palette colors +- Real-time updates when parameters change +- Reset button to restore defaults + +**2. Seed Navigation** +- Display current seed number +- "Previous" and "Next" buttons to cycle through seeds +- "Random" button for random seed +- Input field to jump to specific seed +- Generate 100 variations when requested (seeds 1-100) + +**3. Single Artifact Structure** +```html + + + + + + + + +
+
+ +
+ + + +``` + +**CRITICAL**: This is a single artifact. No external files, no imports (except p5.js CDN). Everything inline. + +**4. Implementation Details - BUILD THE SIDEBAR** + +The sidebar structure: + +**1. Seed (FIXED)** - Always include exactly as shown: +- Seed display +- Prev/Next/Random/Jump buttons + +**2. Parameters (VARIABLE)** - Create controls for the art: +```html +
+ + + ... +
+``` +Add as many control-group divs as there are parameters. + +**3. Colors (OPTIONAL/VARIABLE)** - Include if the art needs adjustable colors: +- Add color pickers if users should control palette +- Skip this section if the art uses fixed colors +- Skip if the art is monochrome + +**4. Actions (FIXED)** - Always include exactly as shown: +- Regenerate button +- Reset button +- Download PNG button + +**Requirements**: +- Seed controls must work (prev/next/random/jump/display) +- All parameters must have UI controls +- Regenerate, Reset, Download buttons must work +- Keep Anthropic branding (UI styling, not art colors) + +### USING THE ARTIFACT + +The HTML artifact works immediately: +1. **In claude.ai**: Displayed as an interactive artifact - runs instantly +2. **As a file**: Save and open in any browser - no server needed +3. **Sharing**: Send the HTML file - it's completely self-contained + +--- + +## VARIATIONS & EXPLORATION + +The artifact includes seed navigation by default (prev/next/random buttons), allowing users to explore variations without creating multiple files. If the user wants specific variations highlighted: + +- Include seed presets (buttons for "Variation 1: Seed 42", "Variation 2: Seed 127", etc.) +- Add a "Gallery Mode" that shows thumbnails of multiple seeds side-by-side +- All within the same single artifact + +This is like creating a series of prints from the same plate - the algorithm is consistent, but each seed reveals different facets of its potential. The interactive nature means users discover their own favorites by exploring the seed space. + +--- + +## THE CREATIVE PROCESS + +**User request** → **Algorithmic philosophy** → **Implementation** + +Each request is unique. The process involves: + +1. **Interpret the user's intent** - What aesthetic is being sought? +2. **Create an algorithmic philosophy** (4-6 paragraphs) describing the computational approach +3. **Implement it in code** - Build the algorithm that expresses this philosophy +4. **Design appropriate parameters** - What should be tunable? +5. **Build matching UI controls** - Sliders/inputs for those parameters + +**The constants**: +- Anthropic branding (colors, fonts, layout) +- Seed navigation (always present) +- Self-contained HTML artifact + +**Everything else is variable**: +- The algorithm itself +- The parameters +- The UI controls +- The visual outcome + +To achieve the best results, trust creativity and let the philosophy guide the implementation. + +--- + +## RESOURCES + +This skill includes helpful templates and documentation: + +- **templates/viewer.html**: REQUIRED STARTING POINT for all HTML artifacts. + - This is the foundation - contains the exact structure and Anthropic branding + - **Keep unchanged**: Layout structure, sidebar organization, Anthropic colors/fonts, seed controls, action buttons + - **Replace**: The p5.js algorithm, parameter definitions, and UI controls in Parameters section + - The extensive comments in the file mark exactly what to keep vs replace + +- **templates/generator_template.js**: Reference for p5.js best practices and code structure principles. + - Shows how to organize parameters, use seeded randomness, structure classes + - NOT a pattern menu - use these principles to build unique algorithms + - Embed algorithms inline in the HTML artifact (don't create separate .js files) + +**Critical reminder**: +- The **template is the STARTING POINT**, not inspiration +- The **algorithm is where to create** something unique +- Don't copy the flow field example - build what the philosophy demands +- But DO keep the exact UI structure and Anthropic branding from the template \ No newline at end of file diff --git a/.agents/skills/algorithmic-art/templates/generator_template.js b/.agents/skills/algorithmic-art/templates/generator_template.js new file mode 100644 index 0000000..e263fbd --- /dev/null +++ b/.agents/skills/algorithmic-art/templates/generator_template.js @@ -0,0 +1,223 @@ +/** + * ═══════════════════════════════════════════════════════════════════════════ + * P5.JS GENERATIVE ART - BEST PRACTICES + * ═══════════════════════════════════════════════════════════════════════════ + * + * This file shows STRUCTURE and PRINCIPLES for p5.js generative art. + * It does NOT prescribe what art you should create. + * + * Your algorithmic philosophy should guide what you build. + * These are just best practices for how to structure your code. + * + * ═══════════════════════════════════════════════════════════════════════════ + */ + +// ============================================================================ +// 1. PARAMETER ORGANIZATION +// ============================================================================ +// Keep all tunable parameters in one object +// This makes it easy to: +// - Connect to UI controls +// - Reset to defaults +// - Serialize/save configurations + +let params = { + // Define parameters that match YOUR algorithm + // Examples (customize for your art): + // - Counts: how many elements (particles, circles, branches, etc.) + // - Scales: size, speed, spacing + // - Probabilities: likelihood of events + // - Angles: rotation, direction + // - Colors: palette arrays + + seed: 12345, + // define colorPalette as an array -- choose whatever colors you'd like ['#d97757', '#6a9bcc', '#788c5d', '#b0aea5'] + // Add YOUR parameters here based on your algorithm +}; + +// ============================================================================ +// 2. SEEDED RANDOMNESS (Critical for reproducibility) +// ============================================================================ +// ALWAYS use seeded random for Art Blocks-style reproducible output + +function initializeSeed(seed) { + randomSeed(seed); + noiseSeed(seed); + // Now all random() and noise() calls will be deterministic +} + +// ============================================================================ +// 3. P5.JS LIFECYCLE +// ============================================================================ + +function setup() { + createCanvas(800, 800); + + // Initialize seed first + initializeSeed(params.seed); + + // Set up your generative system + // This is where you initialize: + // - Arrays of objects + // - Grid structures + // - Initial positions + // - Starting states + + // For static art: call noLoop() at the end of setup + // For animated art: let draw() keep running +} + +function draw() { + // Option 1: Static generation (runs once, then stops) + // - Generate everything in setup() + // - Call noLoop() in setup() + // - draw() doesn't do much or can be empty + + // Option 2: Animated generation (continuous) + // - Update your system each frame + // - Common patterns: particle movement, growth, evolution + // - Can optionally call noLoop() after N frames + + // Option 3: User-triggered regeneration + // - Use noLoop() by default + // - Call redraw() when parameters change +} + +// ============================================================================ +// 4. CLASS STRUCTURE (When you need objects) +// ============================================================================ +// Use classes when your algorithm involves multiple entities +// Examples: particles, agents, cells, nodes, etc. + +class Entity { + constructor() { + // Initialize entity properties + // Use random() here - it will be seeded + } + + update() { + // Update entity state + // This might involve: + // - Physics calculations + // - Behavioral rules + // - Interactions with neighbors + } + + display() { + // Render the entity + // Keep rendering logic separate from update logic + } +} + +// ============================================================================ +// 5. PERFORMANCE CONSIDERATIONS +// ============================================================================ + +// For large numbers of elements: +// - Pre-calculate what you can +// - Use simple collision detection (spatial hashing if needed) +// - Limit expensive operations (sqrt, trig) when possible +// - Consider using p5 vectors efficiently + +// For smooth animation: +// - Aim for 60fps +// - Profile if things are slow +// - Consider reducing particle counts or simplifying calculations + +// ============================================================================ +// 6. UTILITY FUNCTIONS +// ============================================================================ + +// Color utilities +function hexToRgb(hex) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } : null; +} + +function colorFromPalette(index) { + return params.colorPalette[index % params.colorPalette.length]; +} + +// Mapping and easing +function mapRange(value, inMin, inMax, outMin, outMax) { + return outMin + (outMax - outMin) * ((value - inMin) / (inMax - inMin)); +} + +function easeInOutCubic(t) { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; +} + +// Constrain to bounds +function wrapAround(value, max) { + if (value < 0) return max; + if (value > max) return 0; + return value; +} + +// ============================================================================ +// 7. PARAMETER UPDATES (Connect to UI) +// ============================================================================ + +function updateParameter(paramName, value) { + params[paramName] = value; + // Decide if you need to regenerate or just update + // Some params can update in real-time, others need full regeneration +} + +function regenerate() { + // Reinitialize your generative system + // Useful when parameters change significantly + initializeSeed(params.seed); + // Then regenerate your system +} + +// ============================================================================ +// 8. COMMON P5.JS PATTERNS +// ============================================================================ + +// Drawing with transparency for trails/fading +function fadeBackground(opacity) { + fill(250, 249, 245, opacity); // Anthropic light with alpha + noStroke(); + rect(0, 0, width, height); +} + +// Using noise for organic variation +function getNoiseValue(x, y, scale = 0.01) { + return noise(x * scale, y * scale); +} + +// Creating vectors from angles +function vectorFromAngle(angle, magnitude = 1) { + return createVector(cos(angle), sin(angle)).mult(magnitude); +} + +// ============================================================================ +// 9. EXPORT FUNCTIONS +// ============================================================================ + +function exportImage() { + saveCanvas('generative-art-' + params.seed, 'png'); +} + +// ============================================================================ +// REMEMBER +// ============================================================================ +// +// These are TOOLS and PRINCIPLES, not a recipe. +// Your algorithmic philosophy should guide WHAT you create. +// This structure helps you create it WELL. +// +// Focus on: +// - Clean, readable code +// - Parameterized for exploration +// - Seeded for reproducibility +// - Performant execution +// +// The art itself is entirely up to you! +// +// ============================================================================ \ No newline at end of file diff --git a/.agents/skills/algorithmic-art/templates/viewer.html b/.agents/skills/algorithmic-art/templates/viewer.html new file mode 100644 index 0000000..630cc1f --- /dev/null +++ b/.agents/skills/algorithmic-art/templates/viewer.html @@ -0,0 +1,599 @@ + + + + + + + Generative Art Viewer + + + + + + + +
+ + + + +
+
+
Initializing generative art...
+
+
+
+ + + + \ No newline at end of file diff --git a/.agents/skills/brainstorming/SKILL.md b/.agents/skills/brainstorming/SKILL.md new file mode 100644 index 0000000..789c3a1 --- /dev/null +++ b/.agents/skills/brainstorming/SKILL.md @@ -0,0 +1,151 @@ +--- +name: brainstorming +description: "You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation." +--- + +# Brainstorming Ideas Into Designs + +Help turn ideas into fully formed designs and specs through natural collaborative dialogue. + +Start by understanding the current project context, then ask questions one at a time to refine the idea. Once you understand what you're building, present the design and get user approval. + + +Do NOT invoke any implementation skill, write any code, scaffold any project, or take any implementation action until you have presented a design and the user has approved it. This applies to EVERY project regardless of perceived simplicity. + + +## Anti-Pattern: "This Is Too Simple To Need A Design" + +Every project goes through this process. A todo list, a single-function utility, a config change — all of them. "Simple" projects are where unexamined assumptions cause the most wasted work. The design can be short (a few sentences for truly simple projects), but you MUST present it and get approval. + +## Checklist + +You MUST create a task for each of these items and complete them in order: + +1. **Explore project context** — check files, docs, recent commits +2. **Offer the visual companion just-in-time** — NOT upfront. The first time a question would genuinely be clearer shown than described, offer it then (its own message); on approval its browser tab opens for you. If no visual question ever arises, never offer it. See the Visual Companion section below. +3. **Ask clarifying questions** — one at a time, understand purpose/constraints/success criteria +4. **Propose 2-3 approaches** — with trade-offs and your recommendation +5. **Present design** — in sections scaled to their complexity, get user approval after each section +6. **Write design doc** — save to `docs/superpowers/specs/YYYY-MM-DD--design.md` and commit +7. **Spec self-review** — quick inline check for placeholders, contradictions, ambiguity, scope (see below) +8. **User reviews written spec** — ask user to review the spec file before proceeding +9. **Transition to implementation** — invoke writing-plans skill to create implementation plan + +## Process Flow + +```dot +digraph brainstorming { + "Explore project context" [shape=box]; + "Ask clarifying questions" [shape=box]; + "Propose 2-3 approaches" [shape=box]; + "Present design sections" [shape=box]; + "User approves design?" [shape=diamond]; + "Write design doc" [shape=box]; + "Spec self-review\n(fix inline)" [shape=box]; + "User reviews spec?" [shape=diamond]; + "Invoke writing-plans skill" [shape=doublecircle]; + + "Explore project context" -> "Ask clarifying questions"; + "Ask clarifying questions" -> "Propose 2-3 approaches"; + "Propose 2-3 approaches" -> "Present design sections"; + "Present design sections" -> "User approves design?"; + "User approves design?" -> "Present design sections" [label="no, revise"]; + "User approves design?" -> "Write design doc" [label="yes"]; + "Write design doc" -> "Spec self-review\n(fix inline)"; + "Spec self-review\n(fix inline)" -> "User reviews spec?"; + "User reviews spec?" -> "Write design doc" [label="changes requested"]; + "User reviews spec?" -> "Invoke writing-plans skill" [label="approved"]; +} +``` + +**The terminal state is invoking writing-plans.** Do NOT invoke frontend-design, mcp-builder, or any other implementation skill. The ONLY skill you invoke after brainstorming is writing-plans. + +## The Process + +**Understanding the idea:** + +- Check out the current project state first (files, docs, recent commits) +- Before asking detailed questions, assess scope: if the request describes multiple independent subsystems (e.g., "build a platform with chat, file storage, billing, and analytics"), flag this immediately. Don't spend questions refining details of a project that needs to be decomposed first. +- If the project is too large for a single spec, help the user decompose into sub-projects: what are the independent pieces, how do they relate, what order should they be built? Then brainstorm the first sub-project through the normal design flow. Each sub-project gets its own spec → plan → implementation cycle. +- For appropriately-scoped projects, ask questions one at a time to refine the idea +- Prefer multiple choice questions when possible, but open-ended is fine too +- Only one question per message - if a topic needs more exploration, break it into multiple questions +- Focus on understanding: purpose, constraints, success criteria + +**Exploring approaches:** + +- Propose 2-3 different approaches with trade-offs +- Present options conversationally with your recommendation and reasoning +- Lead with your recommended option and explain why +- YAGNI ruthlessly - remove unnecessary features from every approach and design + +**Presenting the design:** + +- Once you believe you understand what you're building, present the design +- Scale each section to its complexity: a few sentences if straightforward, up to 200-300 words if nuanced +- Ask after each section whether it looks right so far +- Cover: architecture, components, data flow, error handling, testing +- Be ready to go back and clarify if something doesn't make sense + +**Design for isolation and clarity:** + +- Break the system into smaller units that each have one clear purpose, communicate through well-defined interfaces, and can be understood and tested independently +- For each unit, you should be able to answer: what does it do, how do you use it, and what does it depend on? +- Can someone understand what a unit does without reading its internals? Can you change the internals without breaking consumers? If not, the boundaries need work. +- Smaller, well-bounded units are also easier for you to work with - you reason better about code you can hold in context at once, and your edits are more reliable when files are focused. When a file grows large, that's often a signal that it's doing too much. + +**Working in existing codebases:** + +- Explore the current structure before proposing changes. Follow existing patterns. +- Where existing code has problems that affect the work (e.g., a file that's grown too large, unclear boundaries, tangled responsibilities), include targeted improvements as part of the design - the way a good developer improves code they're working in. +- Don't propose unrelated refactoring. Stay focused on what serves the current goal. + +## After the Design + +**Documentation:** + +- Write the validated design (spec) to `docs/superpowers/specs/YYYY-MM-DD--design.md` + - (User preferences for spec location override this default) +- Use elements-of-style:writing-clearly-and-concisely skill if available +- Commit the design document to git + +**Spec Self-Review:** +After writing the spec document, look at it with fresh eyes: + +1. **Placeholder scan:** Any "TBD", "TODO", incomplete sections, or vague requirements? Fix them. +2. **Internal consistency:** Do any sections contradict each other? Does the architecture match the feature descriptions? +3. **Scope check:** Is this focused enough for a single implementation plan, or does it need decomposition? +4. **Ambiguity check:** Could any requirement be interpreted two different ways? If so, pick one and make it explicit. + +Fix any issues inline. No need to re-review — just fix and move on. + +**User Review Gate:** +After the spec review loop passes, ask the user to review the written spec before proceeding: + +> "Spec written and committed to ``. Please review it and let me know if you want to make any changes before we start writing out the implementation plan." + +Wait for the user's response. If they request changes, make them and re-run the spec review loop. Only proceed once the user approves. + +**Implementation:** + +- Invoke the writing-plans skill to create a detailed implementation plan +- Do NOT invoke any other skill. writing-plans is the next step. + +## Visual Companion + +A browser-based companion for showing mockups, diagrams, and visual options during brainstorming. Available as a tool — not a mode. Accepting the companion means it's available for questions that benefit from visual treatment; it does NOT mean every question goes through the browser. + +**Offering the companion (just-in-time):** Do NOT offer it upfront. Wait until a question would genuinely be clearer shown than told — a real mockup / layout / diagram question, not merely a UI *topic*. The first time that happens, offer it then, as its own message: +> "This next part might be easier if I show you — I can put together mockups, diagrams, and comparisons in a browser tab as we go. It's still new and can be token-intensive. Want me to? I'll open it for you." + +**This offer MUST be its own message.** Only the offer — no clarifying question, summary, or other content. Wait for the user's response. If they accept, start the server with `--open` so their browser opens to the first screen automatically. If they decline, continue text-only and don't offer again unless they raise it. + +**Per-question decision:** Even after the user accepts, decide FOR EACH QUESTION whether to use the browser or the terminal. The test: **would the user understand this better by seeing it than reading it?** + +- **Use the browser** for content that IS visual — mockups, wireframes, layout comparisons, architecture diagrams, side-by-side visual designs +- **Use the terminal** for content that is text — requirements questions, conceptual choices, tradeoff lists, A/B/C/D text options, scope decisions + +A question about a UI topic is not automatically a visual question. "What does personality mean in this context?" is a conceptual question — use the terminal. "Which wizard layout works better?" is a visual question — use the browser. + +If they agree to the companion, read the detailed guide before proceeding: +`skills/brainstorming/visual-companion.md` diff --git a/.agents/skills/brainstorming/scripts/frame-template.html b/.agents/skills/brainstorming/scripts/frame-template.html new file mode 100644 index 0000000..f540bb8 --- /dev/null +++ b/.agents/skills/brainstorming/scripts/frame-template.html @@ -0,0 +1,213 @@ + + + + + Superpowers Brainstorming + + + +
+ +
Connecting…
+
+ +
+
+ +
+
+ + + diff --git a/.agents/skills/brainstorming/scripts/helper.js b/.agents/skills/brainstorming/scripts/helper.js new file mode 100644 index 0000000..e11d264 --- /dev/null +++ b/.agents/skills/brainstorming/scripts/helper.js @@ -0,0 +1,167 @@ +(function() { + const MIN_RECONNECT_MS = 500; + const MAX_RECONNECT_MS = 30000; + const TOMBSTONE_AFTER_MS = 15000; // show the "paused" overlay after this long disconnected + + // Pure: next backoff delay (doubles, capped). Exported for unit tests. + function nextReconnectDelay(current, max) { + return Math.min(current * 2, max); + } + if (typeof module !== 'undefined' && module.exports) { + module.exports = { nextReconnectDelay, MIN_RECONNECT_MS, MAX_RECONNECT_MS, TOMBSTONE_AFTER_MS }; + } + + // Everything below is browser-only; bail out when loaded in Node (tests). + if (typeof window === 'undefined') return; + + let ws = null; + let eventQueue = []; + let reconnectDelay = MIN_RECONNECT_MS; + let reconnectTimer = null; + let disconnectedSince = null; + let everConnected = false; + let tombstoneShown = false; + + function sessionKey() { + try { + return window.sessionStorage && window.sessionStorage.getItem('brainstorm-session-key'); + } catch (e) {} + return null; + } + + function websocketUrl() { + const key = sessionKey(); + return 'ws://' + window.location.host + (key ? '/?key=' + encodeURIComponent(key) : ''); + } + + function reloadAfterRecovery() { + const key = sessionKey(); + if (key) { + window.location.replace('/?key=' + encodeURIComponent(key)); + } else { + window.location.reload(); + } + } + + // Reflect connection state in the frame's status pill (absent on full-doc screens). + function setStatus(state) { + const el = document.querySelector('.status'); + if (!el) return; + const map = { + connecting: ['Connecting…', 'var(--text-tertiary)'], + connected: ['Connected', 'var(--success)'], + reconnecting: ['Reconnecting…', 'var(--warning)'], + disconnected: ['Disconnected', 'var(--error)'] + }; + const [text, color] = map[state] || map.disconnected; + el.textContent = text; + el.style.setProperty('--status-color', color); + } + + // Self-styled so it works on framed and full-document screens alike. + function showTombstone() { + if (tombstoneShown) return; + tombstoneShown = true; + const el = document.createElement('div'); + el.id = 'bs-tombstone'; + el.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;' + + 'align-items:center;justify-content:center;padding:2rem;text-align:center;' + + 'background:rgba(20,20,22,0.92);color:#f5f5f7;font-family:system-ui,sans-serif'; + el.innerHTML = '
' + + '

Companion paused

' + + '

This brainstorm companion has stopped. ' + + 'Ask your coding agent to bring it back — this page reconnects automatically.

'; + if (document.body) document.body.appendChild(el); + } + + function connect() { + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + setStatus(everConnected ? 'reconnecting' : 'connecting'); + ws = new WebSocket(websocketUrl()); + + ws.onopen = () => { + const recovered = tombstoneShown; + everConnected = true; + disconnectedSince = null; + reconnectDelay = MIN_RECONNECT_MS; + tombstoneShown = false; + setStatus('connected'); + eventQueue.forEach(e => ws.send(JSON.stringify(e))); + eventQueue = []; + // Recovered from a tombstoned outage (e.g. the server restarted on the same + // port) — reload through the keyed bootstrap when possible so the cookie is + // refreshed before the visible URL returns to bare /. + if (recovered) reloadAfterRecovery(); + }; + + ws.onmessage = (msg) => { + let data; + try { data = JSON.parse(msg.data); } catch (e) { return; } + if (data.type === 'reload') window.location.reload(); + }; + + ws.onclose = () => { + ws = null; + if (disconnectedSince === null) disconnectedSince = Date.now(); + if (Date.now() - disconnectedSince >= TOMBSTONE_AFTER_MS) { + setStatus('disconnected'); + showTombstone(); + } else { + setStatus('reconnecting'); + } + reconnectTimer = setTimeout(connect, reconnectDelay); + reconnectDelay = nextReconnectDelay(reconnectDelay, MAX_RECONNECT_MS); + }; + + // Let onclose own reconnection so we don't schedule it twice. + ws.onerror = () => { try { ws.close(); } catch (e) {} }; + } + + function sendEvent(event) { + event.timestamp = Date.now(); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(event)); + } else { + eventQueue.push(event); + } + } + + // Capture clicks on choice elements + document.addEventListener('click', (e) => { + const target = e.target.closest('[data-choice]'); + if (!target) return; + + sendEvent({ + type: 'click', + text: target.textContent.trim(), + choice: target.dataset.choice, + id: target.id || null + }); + + }); + + // Frame UI: selection tracking + window.selectedChoice = null; + + window.toggleSelect = function(el) { + const container = el.closest('.options') || el.closest('.cards'); + const multi = container && container.dataset.multiselect !== undefined; + if (container && !multi) { + container.querySelectorAll('.option, .card').forEach(o => o.classList.remove('selected')); + } + if (multi) { + el.classList.toggle('selected'); + } else { + el.classList.add('selected'); + } + window.selectedChoice = el.dataset.choice; + }; + + // Expose API for explicit use + window.brainstorm = { + send: sendEvent, + choice: (value, metadata = {}) => sendEvent({ type: 'choice', value, ...metadata }) + }; + + connect(); +})(); diff --git a/.agents/skills/brainstorming/scripts/server.cjs b/.agents/skills/brainstorming/scripts/server.cjs new file mode 100644 index 0000000..a828b35 --- /dev/null +++ b/.agents/skills/brainstorming/scripts/server.cjs @@ -0,0 +1,723 @@ +const crypto = require('crypto'); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +// ========== WebSocket Protocol (RFC 6455) ========== + +const OPCODES = { TEXT: 0x01, CLOSE: 0x08, PING: 0x09, PONG: 0x0A }; +const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; +const MAX_FRAME_PAYLOAD_BYTES = 10 * 1024 * 1024; + +function computeAcceptKey(clientKey) { + return crypto.createHash('sha1').update(clientKey + WS_MAGIC).digest('base64'); +} + +function encodeFrame(opcode, payload) { + const fin = 0x80; + const len = payload.length; + let header; + + if (len < 126) { + header = Buffer.alloc(2); + header[0] = fin | opcode; + header[1] = len; + } else if (len < 65536) { + header = Buffer.alloc(4); + header[0] = fin | opcode; + header[1] = 126; + header.writeUInt16BE(len, 2); + } else { + header = Buffer.alloc(10); + header[0] = fin | opcode; + header[1] = 127; + header.writeBigUInt64BE(BigInt(len), 2); + } + + return Buffer.concat([header, payload]); +} + +function decodeFrame(buffer) { + if (buffer.length < 2) return null; + + const secondByte = buffer[1]; + const opcode = buffer[0] & 0x0F; + const masked = (secondByte & 0x80) !== 0; + let payloadLen = secondByte & 0x7F; + let offset = 2; + + if (!masked) throw new Error('Client frames must be masked'); + + if (payloadLen === 126) { + if (buffer.length < 4) return null; + payloadLen = buffer.readUInt16BE(2); + offset = 4; + } else if (payloadLen === 127) { + if (buffer.length < 10) return null; + const extendedLen = buffer.readBigUInt64BE(2); + if (extendedLen > BigInt(MAX_FRAME_PAYLOAD_BYTES)) { + throw new Error('WebSocket frame payload exceeds maximum allowed size'); + } + payloadLen = Number(extendedLen); + offset = 10; + } + + if (payloadLen > MAX_FRAME_PAYLOAD_BYTES) { + throw new Error('WebSocket frame payload exceeds maximum allowed size'); + } + + const maskOffset = offset; + const dataOffset = offset + 4; + const totalLen = dataOffset + payloadLen; + if (buffer.length < totalLen) return null; + + const mask = buffer.slice(maskOffset, dataOffset); + const data = Buffer.alloc(payloadLen); + for (let i = 0; i < payloadLen; i++) { + data[i] = buffer[dataOffset + i] ^ mask[i % 4]; + } + + return { opcode, payload: data, bytesConsumed: totalLen }; +} + +// ========== Configuration ========== + +const PORT_FILE = process.env.BRAINSTORM_PORT_FILE || null; +const randomPort = () => 49152 + Math.floor(Math.random() * 16383); +// Prefer an explicit port, else the port this session last bound (so a restart +// reuses it and an already-open browser tab reconnects), else a random high port. +function preferredPort() { + if (process.env.BRAINSTORM_PORT) return Number(process.env.BRAINSTORM_PORT); + if (PORT_FILE) { + try { + const p = Number(fs.readFileSync(PORT_FILE, 'utf-8').trim()); + if (Number.isInteger(p) && p > 1023 && p < 65536) return p; + } catch (e) { /* no prior port recorded */ } + } + return randomPort(); +} +let PORT = preferredPort(); +const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1'; +const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST); +const SESSION_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm'; +const CONTENT_DIR = path.join(SESSION_DIR, 'content'); +const STATE_DIR = path.join(SESSION_DIR, 'state'); +const SUPERPOWERS_VERSION = readSuperpowersVersion(); +const SUPERPOWERS_BRAND_IMAGE_URL = 'https://primeradiant.com/brand/superpowers-visual-brainstorming-logo.png'; +const TELEMETRY_DISABLE_ENV_VARS = [ + 'SUPERPOWERS_DISABLE_TELEMETRY', + 'DISABLE_TELEMETRY', + 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' +]; +const SUPERPOWERS_TELEMETRY_DISABLED = TELEMETRY_DISABLE_ENV_VARS.some(name => isTruthyEnv(process.env[name])); +let ownerPid = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_OWNER_PID) : null; + +// Per-session secret key. The companion is reachable by any local browser tab +// and, when bound to a non-loopback host, by any host that can route to it. +// The key authenticates the real client uniformly across loopback, tunnel, and +// remote binds — and defeats DNS rebinding — where a Host/Origin allowlist +// cannot. It rides the served URL as ?key= and is mirrored into a cookie on +// first load so same-origin subresources and the WebSocket carry it for free. +// Persisted alongside the port (BRAINSTORM_TOKEN_FILE) so a restart keeps the +// same key and an already-open tab's cookie still validates. +const TOKEN_FILE = process.env.BRAINSTORM_TOKEN_FILE || null; +function generateToken() { + return crypto.randomBytes(32).toString('hex'); +} + +function chmodOwnerOnly(file) { + try { fs.chmodSync(file, 0o600); } catch (e) { /* best effort */ } +} + +function initialToken() { + if (process.env.BRAINSTORM_TOKEN) { + return { value: process.env.BRAINSTORM_TOKEN, source: 'env' }; + } + if (TOKEN_FILE) { + try { + const t = fs.readFileSync(TOKEN_FILE, 'utf-8').trim(); + if (/^[0-9a-f]{32,}$/i.test(t)) { + chmodOwnerOnly(TOKEN_FILE); + return { value: t, source: 'file' }; + } + } catch (e) { /* no prior token recorded */ } + } + return { value: generateToken(), source: 'generated' }; +} + +const tokenInfo = initialToken(); +let TOKEN = tokenInfo.value; +let tokenSource = tokenInfo.source; +let COOKIE_NAME = 'brainstorm-key-' + PORT; // refined to the actual bound port in onListen + +const MIME_TYPES = { + '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', + '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml' +}; + +// ========== Templates and Constants ========== + +function waitingPage() { + return renderBranding(` + +Brainstorm Companion + + +

Brainstorm Companion

+

Waiting for the agent to push a screen...

`); +} + +const FORBIDDEN_PAGE = ` + +Session key required + + +

Session key required

+

This page needs the full URL your coding agent gave you, including the +?key=… part. Copy the complete URL and open it again.

`; + +function bootstrapPage(key) { + const jsonKey = JSON.stringify(String(key)); + return ` + +Opening Brainstorm Companion + + + +`; +} + +const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8'); +const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8'); +const helperInjection = ''; + +// ========== Helper Functions ========== + +function readSuperpowersVersion() { + const root = path.join(__dirname, '../../..'); + const manifests = [ + path.join(root, 'package.json'), + path.join(root, '.codex-plugin/plugin.json') + ]; + + for (const manifest of manifests) { + try { + const data = JSON.parse(fs.readFileSync(manifest, 'utf-8')); + if (data.version) return String(data.version); + } catch (e) { + // Packaged Codex plugins omit package.json; try the next manifest. + } + } + + return 'unknown'; +} + +function isTruthyEnv(value) { + if (!value) return false; + const normalized = String(value).trim().toLowerCase(); + if (!normalized) return false; + return !['0', 'false', 'no', 'off'].includes(normalized); +} + +function escapeHtmlText(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function brandMarkup() { + const version = escapeHtmlText(SUPERPOWERS_VERSION); + const text = SUPERPOWERS_TELEMETRY_DISABLED + ? 'Prime Radiant Superpowers v' + version + : 'Superpowers v' + version; + const logo = SUPERPOWERS_TELEMETRY_DISABLED + ? '' + : ''; + + return ''; +} + +function renderBranding(html) { + return html.split('').join(brandMarkup()); +} + +function isFullDocument(html) { + const trimmed = html.trimStart().toLowerCase(); + return trimmed.startsWith('', content); +} + +function getNewestScreen() { + const files = fs.readdirSync(CONTENT_DIR) + .filter(f => !f.startsWith('.') && f.endsWith('.html')) + .map(f => { + const fp = path.join(CONTENT_DIR, f); + if (!isRegularFileInsideContentDir(fp)) return null; + return { path: fp, mtime: fs.statSync(fp).mtime.getTime() }; + }) + .filter(Boolean) + .sort((a, b) => b.mtime - a.mtime); + return files.length > 0 ? files[0].path : null; +} + +function urlHostForHttp(host) { + const h = String(host); + if (h.startsWith('[') && h.endsWith(']')) return h; + return h.includes(':') ? '[' + h + ']' : h; +} + +function companionUrl() { + return 'http://' + urlHostForHttp(URL_HOST) + ':' + PORT + '/?key=' + TOKEN; +} + +function browserLauncherForPlatform(url, { + platform = process.platform, + osRelease = require('os').release(), + env = process.env +} = {}) { + const isWSL = platform === 'linux' && /microsoft/i.test(osRelease); + if (platform === 'darwin') return { bin: 'open', args: [url] }; + if (platform === 'win32' || isWSL) { + return { bin: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', url] }; + } + if (env.DISPLAY || env.WAYLAND_DISPLAY) return { bin: 'xdg-open', args: [url] }; + return null; +} + +function isRegularFileInsideContentDir(filePath) { + let stat, realContentDir, realFilePath; + try { + stat = fs.lstatSync(filePath); + if (stat.isSymbolicLink()) return false; + if (!stat.isFile()) return false; + if (stat.nlink !== 1) return false; + realContentDir = fs.realpathSync(CONTENT_DIR); + realFilePath = fs.realpathSync(filePath); + } catch (e) { + return false; + } + return realFilePath.startsWith(realContentDir + path.sep); +} + +// ========== Authentication ========== + +function timingSafeEqualStr(a, b) { + const ab = Buffer.from(String(a)); + const bb = Buffer.from(String(b)); + if (ab.length !== bb.length) return false; + return crypto.timingSafeEqual(ab, bb); +} + +function parseCookies(header) { + const out = {}; + if (!header) return out; + for (const part of header.split(';')) { + const eq = part.indexOf('='); + if (eq < 0) continue; + out[part.slice(0, eq).trim()] = part.slice(eq + 1).trim(); + } + return out; +} + +// A request is authorized if it carries the session key as ?key= or as the +// session cookie. Both are compared in constant time. +function isAuthorized(req) { + const q = req.url.indexOf('?'); + if (q >= 0) { + const params = new URLSearchParams(req.url.slice(q + 1)); + if (params.has('key')) { + const key = params.get('key'); + return Boolean(key && timingSafeEqualStr(key, TOKEN)); + } + } + const cookie = parseCookies(req.headers['cookie'])[COOKIE_NAME]; + if (cookie && timingSafeEqualStr(cookie, TOKEN)) return true; + return false; +} + +function pathnameOf(url) { + const q = url.indexOf('?'); + return q >= 0 ? url.slice(0, q) : url; +} + +function queryKey(url) { + const q = url.indexOf('?'); + if (q < 0) return null; + return new URLSearchParams(url.slice(q + 1)).get('key'); +} + +function securityHeaders(headers = {}) { + return { + 'Referrer-Policy': 'no-referrer', + 'Cache-Control': 'no-store', + 'X-Frame-Options': 'DENY', + 'Content-Security-Policy': "frame-ancestors 'none'", + 'Cross-Origin-Resource-Policy': 'same-origin', + ...headers + }; +} + +function isAllowedWebSocketOrigin(req) { + const origin = req.headers.origin; + if (!origin) return true; + const host = req.headers.host; + if (!host) return false; + return origin === 'http://' + host; +} + +// ========== HTTP Request Handler ========== + +function handleRequest(req, res) { + if (!isAuthorized(req)) { + res.writeHead(403, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' })); + res.end(FORBIDDEN_PAGE); + return; + } + touchActivity(); // only authorized requests count as activity + + // Mirror the key into a cookie so same-origin subresources (/files/*) can + // authenticate after bootstrap. HttpOnly keeps it away from page scripts; the + // WebSocket Origin check below is what blocks cross-origin localhost injection. + res.setHeader('Set-Cookie', + COOKIE_NAME + '=' + TOKEN + '; HttpOnly; SameSite=Strict; Path=/'); + + const pathname = pathnameOf(req.url); + const keyFromQuery = queryKey(req.url); + if (req.method === 'GET' && pathname === '/' && keyFromQuery && timingSafeEqualStr(keyFromQuery, TOKEN)) { + res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' })); + res.end(bootstrapPage(keyFromQuery)); + } else if (req.method === 'GET' && pathname === '/') { + const screenFile = getNewestScreen(); + let html = screenFile + ? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8')) + : waitingPage(); + + if (html.includes('')) { + html = html.replace('', helperInjection + '\n'); + } else { + html += helperInjection; + } + + res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' })); + res.end(html); + } else if (req.method === 'GET' && pathname.startsWith('/files/')) { + const fileName = path.basename(pathname.slice(7)); + const filePath = path.join(CONTENT_DIR, fileName); + // Reject empty/dotfile names and anything that isn't a regular file — + // `/files/` would otherwise resolve to CONTENT_DIR and crash readFileSync (EISDIR). + if (!fileName || fileName.startsWith('.') || !isRegularFileInsideContentDir(filePath)) { + res.writeHead(404, securityHeaders()); + res.end('Not found'); + return; + } + const ext = path.extname(filePath).toLowerCase(); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + res.writeHead(200, securityHeaders({ 'Content-Type': contentType })); + res.end(fs.readFileSync(filePath)); + } else { + res.writeHead(404, securityHeaders()); + res.end('Not found'); + } +} + +// ========== WebSocket Connection Handling ========== + +const clients = new Set(); + +function handleUpgrade(req, socket) { + if (!isAuthorized(req) || !isAllowedWebSocketOrigin(req)) { socket.destroy(); return; } + + const key = req.headers['sec-websocket-key']; + if (!key) { socket.destroy(); return; } + + const accept = computeAcceptKey(key); + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + 'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n' + ); + + let buffer = Buffer.alloc(0); + clients.add(socket); + + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length > 0) { + let result; + try { + result = decodeFrame(buffer); + } catch (e) { + socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0))); + clients.delete(socket); + return; + } + if (!result) break; + buffer = buffer.slice(result.bytesConsumed); + + switch (result.opcode) { + case OPCODES.TEXT: + handleMessage(result.payload.toString()); + break; + case OPCODES.CLOSE: + socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0))); + clients.delete(socket); + return; + case OPCODES.PING: + socket.write(encodeFrame(OPCODES.PONG, result.payload)); + break; + case OPCODES.PONG: + break; + default: { + const closeBuf = Buffer.alloc(2); + closeBuf.writeUInt16BE(1003); + socket.end(encodeFrame(OPCODES.CLOSE, closeBuf)); + clients.delete(socket); + return; + } + } + } + }); + + socket.on('close', () => clients.delete(socket)); + socket.on('error', () => clients.delete(socket)); +} + +function handleMessage(text) { + let event; + try { + event = JSON.parse(text); + } catch (e) { + console.error('Failed to parse WebSocket message:', e.message); + return; + } + touchActivity(); + console.log(JSON.stringify({ source: 'user-event', ...event })); + if (event && event.choice) { + const eventsFile = path.join(STATE_DIR, 'events'); + fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n'); + } +} + +function broadcast(msg) { + const frame = encodeFrame(OPCODES.TEXT, Buffer.from(JSON.stringify(msg))); + for (const socket of clients) { + try { socket.write(frame); } catch (e) { clients.delete(socket); } + } +} + +// Best-effort: open the user's browser the first time a screen is actually ready +// to show. Skips when disabled, on a non-loopback (remote) bind, or when a +// browser is already connected. Override the launcher with BRAINSTORM_OPEN_CMD. +let browserOpened = false; +function maybeOpenBrowser() { + if (browserOpened) return; + browserOpened = true; + if (!process.env.BRAINSTORM_OPEN) return; // opt-in: only after the user approves the companion + if (HOST !== '127.0.0.1' && HOST !== 'localhost') return; + if (clients.size > 0) return; // the user already opened it + const url = companionUrl(); // must carry the key or the gate 403s it + const cp = require('child_process'); + // Operator-provided launcher: run as given (this env var is trusted operator input). + if (process.env.BRAINSTORM_OPEN_CMD) { + try { cp.exec(process.env.BRAINSTORM_OPEN_CMD + ' ' + JSON.stringify(url), () => {}); } catch (e) { /* best effort */ } + return; + } + // Platform launchers: pass the URL as an argv element via execFile (no shell), + // so a url-host containing shell metacharacters can't inject a command. + const launcher = browserLauncherForPlatform(url); + if (!launcher) return; // headless: nothing to open + try { cp.execFile(launcher.bin, launcher.args, () => {}); } catch (e) { /* best effort */ } +} + +// ========== Activity Tracking ========== + +// Idle timeout: shut down after this long with no activity. Default 4 hours; +// override with BRAINSTORM_IDLE_TIMEOUT_MS (start-server.sh: --idle-timeout-minutes). +const IDLE_TIMEOUT_MS = (() => { + const ms = Number(process.env.BRAINSTORM_IDLE_TIMEOUT_MS); + return Number.isFinite(ms) && ms > 0 ? ms : 4 * 60 * 60 * 1000; +})(); +// How often the watchdog checks for owner-death / idleness. Configurable mainly +// so tests can run fast; production default is 60s. +const LIFECYCLE_CHECK_MS = (() => { + const ms = Number(process.env.BRAINSTORM_LIFECYCLE_CHECK_MS); + return Number.isFinite(ms) && ms > 0 ? ms : 60 * 1000; +})(); +let lastActivity = Date.now(); + +function touchActivity() { + lastActivity = Date.now(); +} + +// ========== File Watching ========== + +const debounceTimers = new Map(); + +// ========== Server Startup ========== + +function startServer() { + if (!fs.existsSync(CONTENT_DIR)) fs.mkdirSync(CONTENT_DIR, { recursive: true }); + if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true }); + + // Track known files to distinguish new screens from updates. + // macOS fs.watch reports 'rename' for both new files and overwrites, + // so we can't rely on eventType alone. + const knownFiles = new Set( + fs.readdirSync(CONTENT_DIR).filter(f => !f.startsWith('.') && f.endsWith('.html')) + ); + + const server = http.createServer(handleRequest); + server.on('upgrade', handleUpgrade); + + const watcher = fs.watch(CONTENT_DIR, (eventType, filename) => { + if (!filename || filename.startsWith('.') || !filename.endsWith('.html')) return; + + if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename)); + debounceTimers.set(filename, setTimeout(() => { + debounceTimers.delete(filename); + const filePath = path.join(CONTENT_DIR, filename); + + if (!fs.existsSync(filePath)) return; // file was deleted + touchActivity(); + + if (!knownFiles.has(filename)) { + knownFiles.add(filename); + const eventsFile = path.join(STATE_DIR, 'events'); + if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile); + console.log(JSON.stringify({ type: 'screen-added', file: filePath })); + maybeOpenBrowser(); + } else { + console.log(JSON.stringify({ type: 'screen-updated', file: filePath })); + } + + broadcast({ type: 'reload' }); + }, 100)); + }); + watcher.on('error', (err) => console.error('fs.watch error:', err.message)); + + function shutdown(reason) { + console.log(JSON.stringify({ type: 'server-stopped', reason })); + const infoFile = path.join(STATE_DIR, 'server-info'); + if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile); + fs.writeFileSync( + path.join(STATE_DIR, 'server-stopped'), + JSON.stringify({ reason, timestamp: Date.now() }) + '\n' + ); + watcher.close(); + clearInterval(lifecycleCheck); + // Close any upgraded WebSocket sockets so server.close() can complete and + // the process actually exits instead of lingering on an open connection. + for (const socket of clients) { + try { socket.destroy(); } catch (e) { /* already gone */ } + } + server.close(() => process.exit(0)); + } + + function ownerAlive() { + if (!ownerPid) return true; + try { process.kill(ownerPid, 0); return true; } catch (e) { return e.code === 'EPERM'; } + } + + // Periodically exit if the owner process died or we've been idle too long. + const lifecycleCheck = setInterval(() => { + if (!ownerAlive()) shutdown('owner process exited'); + else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) shutdown('idle timeout'); + }, LIFECYCLE_CHECK_MS); + lifecycleCheck.unref(); + + // Validate owner PID at startup. If it's already dead, the PID resolution + // was wrong (common on WSL, Tailscale SSH, and cross-user scenarios). + // Disable monitoring and rely on the idle timeout instead. + if (ownerPid) { + try { process.kill(ownerPid, 0); } + catch (e) { + if (e.code !== 'EPERM') { + console.log(JSON.stringify({ type: 'owner-pid-invalid', pid: ownerPid, reason: 'dead at startup' })); + ownerPid = null; + } + } + } + + // If the preferred port is already taken (e.g. a previous server is still + // alive), fall back to a random port once instead of failing. + let triedFallback = false; + + function onListen() { + // Cookie name keys on the ACTUAL bound port (may differ from the preferred + // one after an EADDRINUSE fallback) so it can't collide with another server's + // cookie in the shared localhost jar. + COOKIE_NAME = 'brainstorm-key-' + PORT; + // Record the bound port AND token so the next restart of this session reuses + // them — but ONLY when we got our preferred port. On a fallback we bound a + // *different* port because someone else holds the preferred one; persisting + // would overwrite the shared files and strand that other session's open tab. + if (PORT_FILE && !triedFallback) { + try { fs.writeFileSync(PORT_FILE, String(PORT)); } catch (e) { /* best effort */ } + if (TOKEN_FILE) { + try { + fs.writeFileSync(TOKEN_FILE, TOKEN, { mode: 0o600 }); + chmodOwnerOnly(TOKEN_FILE); + } catch (e) { /* best effort */ } + } + } + const info = JSON.stringify({ + type: 'server-started', port: Number(PORT), host: HOST, + url_host: URL_HOST, url: companionUrl(), + screen_dir: CONTENT_DIR, state_dir: STATE_DIR, idle_timeout_ms: IDLE_TIMEOUT_MS + }); + console.log(info); + // server-info embeds the key — keep it owner-only. + fs.writeFileSync(path.join(STATE_DIR, 'server-info'), info + '\n', { mode: 0o600 }); + } + + server.on('error', (err) => { + if (err.code === 'EADDRINUSE' && !triedFallback) { + if (tokenSource === 'env') { + console.error('Server failed to bind: preferred port is in use and BRAINSTORM_TOKEN is set; refusing fallback with explicit token'); + process.exit(1); + } + triedFallback = true; + PORT = randomPort(); + if (tokenSource === 'file') { + TOKEN = generateToken(); + tokenSource = 'generated-fallback'; + } + server.listen(PORT, HOST, onListen); + } else { + console.error('Server failed to bind:', err.message); + process.exit(1); + } + }); + server.listen(PORT, HOST, onListen); +} + +if (require.main === module) { + startServer(); +} + +module.exports = { + computeAcceptKey, + encodeFrame, + decodeFrame, + browserLauncherForPlatform, + OPCODES, + MAX_FRAME_PAYLOAD_BYTES +}; diff --git a/.agents/skills/brainstorming/scripts/start-server.sh b/.agents/skills/brainstorming/scripts/start-server.sh new file mode 100755 index 0000000..016a8e4 --- /dev/null +++ b/.agents/skills/brainstorming/scripts/start-server.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# Start the brainstorm server and output connection info +# Usage: start-server.sh [--project-dir ] [--host ] [--url-host ] [--foreground] [--background] +# +# Starts server on a random high port, outputs JSON with URL. +# Each session gets its own directory to avoid conflicts. +# +# Options: +# --project-dir Store session files under /.superpowers/brainstorm/ +# instead of /tmp. Files persist after server stops. +# --host Host/interface to bind (default: 127.0.0.1). +# Use 0.0.0.0 in remote/containerized environments. +# --url-host Hostname shown in returned URL JSON. +# --idle-timeout-minutes Shut down after n minutes idle (default 240 = 4h). +# --open Auto-open the browser on the first screen (use only +# after the user approves the visual companion). +# --foreground Run server in the current terminal (no backgrounding). +# --background Force background mode (overrides Codex auto-foreground). + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Parse arguments +PROJECT_DIR="" +FOREGROUND="false" +FORCE_BACKGROUND="false" +BIND_HOST="127.0.0.1" +URL_HOST="" +IDLE_TIMEOUT_MINUTES="" +while [[ $# -gt 0 ]]; do + case "$1" in + --project-dir) + PROJECT_DIR="$2" + shift 2 + ;; + --host) + BIND_HOST="$2" + shift 2 + ;; + --url-host) + URL_HOST="$2" + shift 2 + ;; + --idle-timeout-minutes) + IDLE_TIMEOUT_MINUTES="$2" + shift 2 + ;; + --open) + export BRAINSTORM_OPEN=1 + shift + ;; + --foreground|--no-daemon) + FOREGROUND="true" + shift + ;; + --background|--daemon) + FORCE_BACKGROUND="true" + shift + ;; + *) + echo "{\"error\": \"Unknown argument: $1\"}" + exit 1 + ;; + esac +done + +if [[ -z "$URL_HOST" ]]; then + if [[ "$BIND_HOST" == "127.0.0.1" || "$BIND_HOST" == "localhost" ]]; then + URL_HOST="localhost" + else + URL_HOST="$BIND_HOST" + fi +fi + +if [[ -n "$IDLE_TIMEOUT_MINUTES" ]]; then + if ! [[ "$IDLE_TIMEOUT_MINUTES" =~ ^[0-9]+$ ]] || [[ "$IDLE_TIMEOUT_MINUTES" -lt 1 ]]; then + echo "{\"error\": \"--idle-timeout-minutes must be a positive integer\"}" + exit 1 + fi + export BRAINSTORM_IDLE_TIMEOUT_MS=$(( IDLE_TIMEOUT_MINUTES * 60 * 1000 )) +fi + +is_windows_like_shell() { + case "${OSTYPE:-}" in + msys*|cygwin*|mingw*) return 0 ;; + esac + if [[ -n "${MSYSTEM:-}" ]]; then + return 0 + fi + local uname_s + uname_s="$(uname -s 2>/dev/null || true)" + case "$uname_s" in + MSYS*|MINGW*|CYGWIN*) return 0 ;; + esac + return 1 +} + +# Some environments reap detached/background processes. Auto-foreground when detected. +if [[ -n "${CODEX_CI:-}" && "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then + FOREGROUND="true" +fi + +# Windows/Git Bash reaps nohup background processes. Auto-foreground when detected. +if [[ "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then + if is_windows_like_shell; then + FOREGROUND="true" + fi +fi + +# Session files (server.log, server-info, .last-token) embed the session key — +# keep everything this script and the server create owner-only. +umask 077 + +# Generate unique session directory +SESSION_ID="$$-$(date +%s)" + +if [[ -n "$PROJECT_DIR" ]]; then + SESSION_DIR="${PROJECT_DIR}/.superpowers/brainstorm/${SESSION_ID}" + # Persist the bound port and key per project so a restart reuses them and an + # already-open browser tab reconnects to the same URL with a valid cookie. + export BRAINSTORM_PORT_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-port" + export BRAINSTORM_TOKEN_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-token" +else + SESSION_DIR="/tmp/brainstorm-${SESSION_ID}" +fi + +STATE_DIR="${SESSION_DIR}/state" +PID_FILE="${STATE_DIR}/server.pid" +LOG_FILE="${STATE_DIR}/server.log" +SERVER_ID_FILE="${STATE_DIR}/server-instance-id" + +# Create fresh session directory with content and state peers +mkdir -p "${SESSION_DIR}/content" "$STATE_DIR" + +SERVER_ID="" +if [[ -r /dev/urandom ]]; then + SERVER_ID="$(od -An -N24 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n' || true)" +fi +if ! [[ "$SERVER_ID" =~ ^[A-Za-z0-9_-]{32,64}$ ]]; then + SERVER_ID="$(printf '%08x%08x%08x%08x' "$$" "$(date +%s)" "${RANDOM:-0}" "${RANDOM:-0}")" +fi +printf '%s\n' "$SERVER_ID" > "$SERVER_ID_FILE" +chmod 600 "$SERVER_ID_FILE" 2>/dev/null || true + +# Kill any existing server +if [[ -f "$PID_FILE" ]]; then + old_pid=$(cat "$PID_FILE") + kill "$old_pid" 2>/dev/null + rm -f "$PID_FILE" +fi + +cd "$SCRIPT_DIR" || exit 1 + +# Resolve the harness PID (grandparent of this script). +# $PPID is the ephemeral shell the harness spawned to run us — it dies +# when this script exits. The harness itself is $PPID's parent. +OWNER_PID="$(ps -o ppid= -p "$PPID" 2>/dev/null | tr -d ' ')" +if [[ -z "$OWNER_PID" || "$OWNER_PID" == "1" ]]; then + OWNER_PID="$PPID" +fi + +# Windows/MSYS2: Node.js cannot see POSIX PIDs from the MSYS2 namespace. +# Passing a PID node cannot verify causes server to log owner-pid-invalid +# and self-terminate at the 60-second lifecycle check. Clear it so the +# watchdog is disabled and the idle timeout becomes the only shutdown trigger. +if is_windows_like_shell; then + OWNER_PID="" +fi + +# Foreground mode for environments that reap detached/background processes. +if [[ "$FOREGROUND" == "true" ]]; then + env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs "--brainstorm-server-id=$SERVER_ID" & + SERVER_PID=$! + echo "$SERVER_PID" > "$PID_FILE" + wait "$SERVER_PID" + exit $? +fi + +# Start server, capturing output to log file +# Use nohup to survive shell exit; disown to remove from job table +nohup env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs "--brainstorm-server-id=$SERVER_ID" > "$LOG_FILE" 2>&1 & +SERVER_PID=$! +disown "$SERVER_PID" 2>/dev/null +echo "$SERVER_PID" > "$PID_FILE" + +# Wait for server-started message (check log file) +for _ in {1..50}; do + if grep -q "server-started" "$LOG_FILE" 2>/dev/null; then + # Verify server is still alive after a short window (catches process reapers) + alive="true" + for _ in {1..20}; do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + alive="false" + break + fi + sleep 0.1 + done + if [[ "$alive" != "true" ]]; then + echo "{\"error\": \"Server started but was killed. Retry in a persistent terminal with: $SCRIPT_DIR/start-server.sh${PROJECT_DIR:+ --project-dir $PROJECT_DIR} --host $BIND_HOST --url-host $URL_HOST --foreground\"}" + exit 1 + fi + grep "server-started" "$LOG_FILE" | head -1 + exit 0 + fi + sleep 0.1 +done + +# Timeout - server didn't start +echo '{"error": "Server failed to start within 5 seconds"}' +exit 1 diff --git a/.agents/skills/brainstorming/scripts/stop-server.sh b/.agents/skills/brainstorming/scripts/stop-server.sh new file mode 100755 index 0000000..7cacfe9 --- /dev/null +++ b/.agents/skills/brainstorming/scripts/stop-server.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Stop the brainstorm server and clean up +# Usage: stop-server.sh +# +# Kills the server process. Only deletes session directory if it's +# under /tmp (ephemeral). Persistent directories (.superpowers/) are +# kept so mockups can be reviewed later. + +SESSION_DIR="$1" + +if [[ -z "$SESSION_DIR" ]]; then + echo '{"error": "Usage: stop-server.sh "}' + exit 1 +fi + +STATE_DIR="${SESSION_DIR}/state" +PID_FILE="${STATE_DIR}/server.pid" +SERVER_ID_FILE="${STATE_DIR}/server-instance-id" + +mark_stopped() { + local reason="$1" + rm -f "${STATE_DIR}/server-info" + printf '{"reason":"%s","timestamp":%s}\n' "$reason" "$(date +%s)" > "${STATE_DIR}/server-stopped" +} + +read_expected_server_id() { + [[ -f "$SERVER_ID_FILE" ]] || return 1 + local id + id="$(tr -d '\r\n' < "$SERVER_ID_FILE" 2>/dev/null || true)" + [[ "$id" =~ ^[A-Za-z0-9_-]{32,64}$ ]] || return 1 + printf '%s\n' "$id" +} + +command_line_for_pid() { + local pid="$1" + if [[ -r "/proc/$pid/cmdline" ]]; then + tr '\0' '\n' < "/proc/$pid/cmdline" 2>/dev/null || true + return 0 + fi + ps -ww -p "$pid" -o command= 2>/dev/null || ps -f -p "$pid" 2>/dev/null | sed '1d' || true +} + +command_has_server_id() { + local pid="$1" + local expected="$2" + local expected_arg="--brainstorm-server-id=$expected" + if [[ -r "/proc/$pid/cmdline" ]]; then + local arg + while IFS= read -r -d '' arg || [[ -n "$arg" ]]; do + [[ "$arg" == "$expected_arg" ]] && return 0 + done < "/proc/$pid/cmdline" + return 1 + fi + local command_line + command_line="$(command_line_for_pid "$pid")" + [[ -n "$command_line" ]] || return 1 + case " $command_line " in + *" $expected_arg "*) return 0 ;; + *) return 1 ;; + esac +} + +# Confirm a PID has this session's per-start instance id, not just a familiar +# process name. Ambiguous or legacy metadata fails closed as stale_pid. +is_brainstorm_server() { + kill -0 "$1" 2>/dev/null || return 1 + local expected_id + expected_id="$(read_expected_server_id)" || return 1 + command_has_server_id "$1" "$expected_id" || return 1 + return 0 +} + +if [[ -f "$PID_FILE" ]]; then + pid=$(cat "$PID_FILE") + + # Refuse to signal a PID we can't prove is our server. A stale pid file may + # point at an unrelated process after a reboot/PID wraparound. + if ! is_brainstorm_server "$pid"; then + rm -f "$PID_FILE" "$SERVER_ID_FILE" + mark_stopped "stale_pid" + echo '{"status": "stale_pid"}' + exit 0 + fi + + # Try to stop gracefully, fallback to force if still alive + kill "$pid" 2>/dev/null || true + + # Wait for graceful shutdown (up to ~2s) + for _ in {1..20}; do + if ! kill -0 "$pid" 2>/dev/null; then + break + fi + sleep 0.1 + done + + # If still running, escalate to SIGKILL + if kill -0 "$pid" 2>/dev/null; then + kill -9 "$pid" 2>/dev/null || true + + # Give SIGKILL a moment to take effect + sleep 0.1 + fi + + if kill -0 "$pid" 2>/dev/null; then + echo '{"status": "failed", "error": "process still running"}' + exit 1 + fi + + rm -f "$PID_FILE" "$SERVER_ID_FILE" "${STATE_DIR}/server.log" + mark_stopped "stop-server.sh" + + # Only delete ephemeral /tmp directories + if [[ "$SESSION_DIR" == /tmp/* ]]; then + rm -rf "$SESSION_DIR" + fi + + echo '{"status": "stopped"}' +else + echo '{"status": "not_running"}' +fi diff --git a/.agents/skills/brainstorming/spec-document-reviewer-prompt.md b/.agents/skills/brainstorming/spec-document-reviewer-prompt.md new file mode 100644 index 0000000..6099312 --- /dev/null +++ b/.agents/skills/brainstorming/spec-document-reviewer-prompt.md @@ -0,0 +1,49 @@ +# Spec Document Reviewer Prompt Template + +Use this template when dispatching a spec document reviewer subagent. + +**Purpose:** Verify the spec is complete, consistent, and ready for implementation planning. + +**Dispatch after:** Spec document is written to docs/superpowers/specs/ + +``` +Subagent (general-purpose): + description: "Review spec document" + prompt: | + You are a spec document reviewer. Verify this spec is complete and ready for planning. + + **Spec to review:** [SPEC_FILE_PATH] + + ## What to Check + + | Category | What to Look For | + |----------|------------------| + | Completeness | TODOs, placeholders, "TBD", incomplete sections | + | Consistency | Internal contradictions, conflicting requirements | + | Clarity | Requirements ambiguous enough to cause someone to build the wrong thing | + | Scope | Focused enough for a single plan — not covering multiple independent subsystems | + | YAGNI | Unrequested features, over-engineering | + + ## Calibration + + **Only flag issues that would cause real problems during implementation planning.** + A missing section, a contradiction, or a requirement so ambiguous it could be + interpreted two different ways — those are issues. Minor wording improvements, + stylistic preferences, and "sections less detailed than others" are not. + + Approve unless there are serious gaps that would lead to a flawed plan. + + ## Output Format + + ## Spec Review + + **Status:** Approved | Issues Found + + **Issues (if any):** + - [Section X]: [specific issue] - [why it matters for planning] + + **Recommendations (advisory, do not block approval):** + - [suggestions for improvement] +``` + +**Reviewer returns:** Status, Issues (if any), Recommendations diff --git a/.agents/skills/brainstorming/visual-companion.md b/.agents/skills/brainstorming/visual-companion.md new file mode 100644 index 0000000..906c9ac --- /dev/null +++ b/.agents/skills/brainstorming/visual-companion.md @@ -0,0 +1,298 @@ +# Visual Companion Guide + +Browser-based visual brainstorming companion for showing mockups, diagrams, and options. + +## When to Use + +Decide per-question, not per-session. The test: **would the user understand this better by seeing it than reading it?** + +**Use the browser** when the content itself is visual: + +- **UI mockups** — wireframes, layouts, navigation structures, component designs +- **Architecture diagrams** — system components, data flow, relationship maps +- **Side-by-side visual comparisons** — comparing two layouts, two color schemes, two design directions +- **Design polish** — when the question is about look and feel, spacing, visual hierarchy +- **Spatial relationships** — state machines, flowcharts, entity relationships rendered as diagrams + +**Use the terminal** when the content is text or tabular: + +- **Requirements and scope questions** — "what does X mean?", "which features are in scope?" +- **Conceptual A/B/C choices** — picking between approaches described in words +- **Tradeoff lists** — pros/cons, comparison tables +- **Technical decisions** — API design, data modeling, architectural approach selection +- **Clarifying questions** — anything where the answer is words, not a visual preference + +A question *about* a UI topic is not automatically a visual question. "What kind of wizard do you want?" is conceptual — use the terminal. "Which of these wizard layouts feels right?" is visual — use the browser. + +## How It Works + +The server watches a directory for HTML files and serves the newest one to the browser. You write HTML content to `screen_dir`, the user sees it in their browser and can click to select options. Selections are recorded to `state_dir/events` that you read on your next turn. + +**Content fragments vs full documents:** If your HTML file starts with `/.superpowers/brainstorm/` for the session directory. + +**Note:** Pass the project root as `--project-dir` so mockups persist in `.superpowers/brainstorm/` and survive server restarts. Without it, files go to `/tmp` and get cleaned up. Remind the user to add `.superpowers/` to `.gitignore` if it's not already there. + +**Launching the server by platform:** + +**Claude Code:** +```bash +# Default mode works — the script backgrounds the server itself. +scripts/start-server.sh --project-dir /path/to/project --open +``` + +On Windows, the script auto-detects and switches to foreground mode (which blocks the tool call). Use `run_in_background: true` on the Bash tool call so the server survives across conversation turns, then read `$STATE_DIR/server-info` on the next turn to get the URL and port. + +**Codex:** +```bash +# Codex reaps background processes. The script auto-detects CODEX_CI and +# switches to foreground mode. Run it normally — no extra flags needed. +scripts/start-server.sh --project-dir /path/to/project --open +``` + +**Gemini CLI:** +```bash +# Use --foreground and set is_background: true on your shell tool call +# so the process survives across turns +scripts/start-server.sh --project-dir /path/to/project --open --foreground +``` + +**Copilot CLI:** +```bash +# Use --foreground and start the server via the bash tool with mode: "async" +# so the process survives across turns. Capture the returned shellId for +# read_bash / stop_bash if you need to interact with it later. +scripts/start-server.sh --project-dir /path/to/project --open --foreground +``` + +**Other environments:** The server must keep running in the background across conversation turns. If your environment reaps detached processes, use `--foreground` and launch the command with your platform's background execution mechanism. + +If the URL is unreachable from your browser (common in remote/containerized setups), bind a non-loopback host: + +```bash +scripts/start-server.sh \ + --project-dir /path/to/project \ + --host 0.0.0.0 \ + --url-host localhost +``` + +Use `--url-host` to control what hostname is printed in the returned URL JSON. + +## The Loop + +1. **Check server is alive**, then **write HTML** to a new file in `screen_dir`: + - **Required: confirm the server is alive before referring to the URL or pushing a screen.** Check that `$STATE_DIR/server-info` exists and `$STATE_DIR/server-stopped` does not. If it has shut down, restart it with `start-server.sh` using the **same `--project-dir`** — it reuses the same port, so the user's open tab reconnects on its own (it shows a "paused" overlay while the server is down) and you don't need to send a new URL. The server auto-exits after 4 hours idle (configurable with `--idle-timeout-minutes`). + - Use semantic filenames: `platform.html`, `visual-style.html`, `layout.html` + - **Never reuse filenames** — each screen gets a fresh file + - Use your file-creation tool — **never use cat/heredoc** (dumps noise into terminal) + - Server automatically serves the newest file + +2. **Tell user what to expect and end your turn:** + - Remind them of the URL (every step, not just first) + - Give a brief text summary of what's on screen (e.g., "Showing 3 layout options for the homepage") + - Ask them to respond in the terminal: "Take a look and let me know what you think. Click to select an option if you'd like." + +3. **On your next turn** — after the user responds in the terminal: + - Read `$STATE_DIR/events` if it exists — this contains the user's browser interactions (clicks, selections) as JSON lines + - Merge with the user's terminal text to get the full picture + - The terminal message is the primary feedback; `state_dir/events` provides structured interaction data + +4. **Iterate or advance** — if feedback changes current screen, write a new file (e.g., `layout-v2.html`). Only move to the next question when the current step is validated. + +5. **Unload when returning to terminal** — when the next step doesn't need the browser (e.g., a clarifying question, a tradeoff discussion), push a waiting screen to clear the stale content: + + ```html + +
+

Continuing in terminal...

+
+ ``` + + This prevents the user from staring at a resolved choice while the conversation has moved on. When the next visual question comes up, push a new content file as usual. + +6. Repeat until done. + +## Writing Content Fragments + +Write just the content that goes inside the page. The server wraps it in the frame template automatically (header, theme CSS, connection status, and all interactive infrastructure). + +**Minimal example:** + +```html +

Which layout works better?

+

Consider readability and visual hierarchy

+ +
+
+
A
+
+

Single Column

+

Clean, focused reading experience

+
+
+
+
B
+
+

Two Column

+

Sidebar navigation with main content

+
+
+
+``` + +That's it. No ``, no CSS, no ` + + diff --git a/.agents/skills/skill-creator/eval-viewer/generate_review.py b/.agents/skills/skill-creator/eval-viewer/generate_review.py new file mode 100644 index 0000000..7fa5978 --- /dev/null +++ b/.agents/skills/skill-creator/eval-viewer/generate_review.py @@ -0,0 +1,471 @@ +#!/usr/bin/env python3 +"""Generate and serve a review page for eval results. + +Reads the workspace directory, discovers runs (directories with outputs/), +embeds all output data into a self-contained HTML page, and serves it via +a tiny HTTP server. Feedback auto-saves to feedback.json in the workspace. + +Usage: + python generate_review.py [--port PORT] [--skill-name NAME] + python generate_review.py --previous-feedback /path/to/old/feedback.json + +No dependencies beyond the Python stdlib are required. +""" + +import argparse +import base64 +import json +import mimetypes +import os +import re +import signal +import subprocess +import sys +import time +import webbrowser +from functools import partial +from http.server import HTTPServer, BaseHTTPRequestHandler +from pathlib import Path + +# Files to exclude from output listings +METADATA_FILES = {"transcript.md", "user_notes.md", "metrics.json"} + +# Extensions we render as inline text +TEXT_EXTENSIONS = { + ".txt", ".md", ".json", ".csv", ".py", ".js", ".ts", ".tsx", ".jsx", + ".yaml", ".yml", ".xml", ".html", ".css", ".sh", ".rb", ".go", ".rs", + ".java", ".c", ".cpp", ".h", ".hpp", ".sql", ".r", ".toml", +} + +# Extensions we render as inline images +IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"} + +# MIME type overrides for common types +MIME_OVERRIDES = { + ".svg": "image/svg+xml", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", +} + + +def get_mime_type(path: Path) -> str: + ext = path.suffix.lower() + if ext in MIME_OVERRIDES: + return MIME_OVERRIDES[ext] + mime, _ = mimetypes.guess_type(str(path)) + return mime or "application/octet-stream" + + +def find_runs(workspace: Path) -> list[dict]: + """Recursively find directories that contain an outputs/ subdirectory.""" + runs: list[dict] = [] + _find_runs_recursive(workspace, workspace, runs) + runs.sort(key=lambda r: (r.get("eval_id", float("inf")), r["id"])) + return runs + + +def _find_runs_recursive(root: Path, current: Path, runs: list[dict]) -> None: + if not current.is_dir(): + return + + outputs_dir = current / "outputs" + if outputs_dir.is_dir(): + run = build_run(root, current) + if run: + runs.append(run) + return + + skip = {"node_modules", ".git", "__pycache__", "skill", "inputs"} + for child in sorted(current.iterdir()): + if child.is_dir() and child.name not in skip: + _find_runs_recursive(root, child, runs) + + +def build_run(root: Path, run_dir: Path) -> dict | None: + """Build a run dict with prompt, outputs, and grading data.""" + prompt = "" + eval_id = None + + # Try eval_metadata.json + for candidate in [run_dir / "eval_metadata.json", run_dir.parent / "eval_metadata.json"]: + if candidate.exists(): + try: + metadata = json.loads(candidate.read_text()) + prompt = metadata.get("prompt", "") + eval_id = metadata.get("eval_id") + except (json.JSONDecodeError, OSError): + pass + if prompt: + break + + # Fall back to transcript.md + if not prompt: + for candidate in [run_dir / "transcript.md", run_dir / "outputs" / "transcript.md"]: + if candidate.exists(): + try: + text = candidate.read_text() + match = re.search(r"## Eval Prompt\n\n([\s\S]*?)(?=\n##|$)", text) + if match: + prompt = match.group(1).strip() + except OSError: + pass + if prompt: + break + + if not prompt: + prompt = "(No prompt found)" + + run_id = str(run_dir.relative_to(root)).replace("/", "-").replace("\\", "-") + + # Collect output files + outputs_dir = run_dir / "outputs" + output_files: list[dict] = [] + if outputs_dir.is_dir(): + for f in sorted(outputs_dir.iterdir()): + if f.is_file() and f.name not in METADATA_FILES: + output_files.append(embed_file(f)) + + # Load grading if present + grading = None + for candidate in [run_dir / "grading.json", run_dir.parent / "grading.json"]: + if candidate.exists(): + try: + grading = json.loads(candidate.read_text()) + except (json.JSONDecodeError, OSError): + pass + if grading: + break + + return { + "id": run_id, + "prompt": prompt, + "eval_id": eval_id, + "outputs": output_files, + "grading": grading, + } + + +def embed_file(path: Path) -> dict: + """Read a file and return an embedded representation.""" + ext = path.suffix.lower() + mime = get_mime_type(path) + + if ext in TEXT_EXTENSIONS: + try: + content = path.read_text(errors="replace") + except OSError: + content = "(Error reading file)" + return { + "name": path.name, + "type": "text", + "content": content, + } + elif ext in IMAGE_EXTENSIONS: + try: + raw = path.read_bytes() + b64 = base64.b64encode(raw).decode("ascii") + except OSError: + return {"name": path.name, "type": "error", "content": "(Error reading file)"} + return { + "name": path.name, + "type": "image", + "mime": mime, + "data_uri": f"data:{mime};base64,{b64}", + } + elif ext == ".pdf": + try: + raw = path.read_bytes() + b64 = base64.b64encode(raw).decode("ascii") + except OSError: + return {"name": path.name, "type": "error", "content": "(Error reading file)"} + return { + "name": path.name, + "type": "pdf", + "data_uri": f"data:{mime};base64,{b64}", + } + elif ext == ".xlsx": + try: + raw = path.read_bytes() + b64 = base64.b64encode(raw).decode("ascii") + except OSError: + return {"name": path.name, "type": "error", "content": "(Error reading file)"} + return { + "name": path.name, + "type": "xlsx", + "data_b64": b64, + } + else: + # Binary / unknown — base64 download link + try: + raw = path.read_bytes() + b64 = base64.b64encode(raw).decode("ascii") + except OSError: + return {"name": path.name, "type": "error", "content": "(Error reading file)"} + return { + "name": path.name, + "type": "binary", + "mime": mime, + "data_uri": f"data:{mime};base64,{b64}", + } + + +def load_previous_iteration(workspace: Path) -> dict[str, dict]: + """Load previous iteration's feedback and outputs. + + Returns a map of run_id -> {"feedback": str, "outputs": list[dict]}. + """ + result: dict[str, dict] = {} + + # Load feedback + feedback_map: dict[str, str] = {} + feedback_path = workspace / "feedback.json" + if feedback_path.exists(): + try: + data = json.loads(feedback_path.read_text()) + feedback_map = { + r["run_id"]: r["feedback"] + for r in data.get("reviews", []) + if r.get("feedback", "").strip() + } + except (json.JSONDecodeError, OSError, KeyError): + pass + + # Load runs (to get outputs) + prev_runs = find_runs(workspace) + for run in prev_runs: + result[run["id"]] = { + "feedback": feedback_map.get(run["id"], ""), + "outputs": run.get("outputs", []), + } + + # Also add feedback for run_ids that had feedback but no matching run + for run_id, fb in feedback_map.items(): + if run_id not in result: + result[run_id] = {"feedback": fb, "outputs": []} + + return result + + +def generate_html( + runs: list[dict], + skill_name: str, + previous: dict[str, dict] | None = None, + benchmark: dict | None = None, +) -> str: + """Generate the complete standalone HTML page with embedded data.""" + template_path = Path(__file__).parent / "viewer.html" + template = template_path.read_text() + + # Build previous_feedback and previous_outputs maps for the template + previous_feedback: dict[str, str] = {} + previous_outputs: dict[str, list[dict]] = {} + if previous: + for run_id, data in previous.items(): + if data.get("feedback"): + previous_feedback[run_id] = data["feedback"] + if data.get("outputs"): + previous_outputs[run_id] = data["outputs"] + + embedded = { + "skill_name": skill_name, + "runs": runs, + "previous_feedback": previous_feedback, + "previous_outputs": previous_outputs, + } + if benchmark: + embedded["benchmark"] = benchmark + + data_json = json.dumps(embedded) + + return template.replace("/*__EMBEDDED_DATA__*/", f"const EMBEDDED_DATA = {data_json};") + + +# --------------------------------------------------------------------------- +# HTTP server (stdlib only, zero dependencies) +# --------------------------------------------------------------------------- + +def _kill_port(port: int) -> None: + """Kill any process listening on the given port.""" + try: + result = subprocess.run( + ["lsof", "-ti", f":{port}"], + capture_output=True, text=True, timeout=5, + ) + for pid_str in result.stdout.strip().split("\n"): + if pid_str.strip(): + try: + os.kill(int(pid_str.strip()), signal.SIGTERM) + except (ProcessLookupError, ValueError): + pass + if result.stdout.strip(): + time.sleep(0.5) + except subprocess.TimeoutExpired: + pass + except FileNotFoundError: + print("Note: lsof not found, cannot check if port is in use", file=sys.stderr) + +class ReviewHandler(BaseHTTPRequestHandler): + """Serves the review HTML and handles feedback saves. + + Regenerates the HTML on each page load so that refreshing the browser + picks up new eval outputs without restarting the server. + """ + + def __init__( + self, + workspace: Path, + skill_name: str, + feedback_path: Path, + previous: dict[str, dict], + benchmark_path: Path | None, + *args, + **kwargs, + ): + self.workspace = workspace + self.skill_name = skill_name + self.feedback_path = feedback_path + self.previous = previous + self.benchmark_path = benchmark_path + super().__init__(*args, **kwargs) + + def do_GET(self) -> None: + if self.path == "/" or self.path == "/index.html": + # Regenerate HTML on each request (re-scans workspace for new outputs) + runs = find_runs(self.workspace) + benchmark = None + if self.benchmark_path and self.benchmark_path.exists(): + try: + benchmark = json.loads(self.benchmark_path.read_text()) + except (json.JSONDecodeError, OSError): + pass + html = generate_html(runs, self.skill_name, self.previous, benchmark) + content = html.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(content))) + self.end_headers() + self.wfile.write(content) + elif self.path == "/api/feedback": + data = b"{}" + if self.feedback_path.exists(): + data = self.feedback_path.read_bytes() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + else: + self.send_error(404) + + def do_POST(self) -> None: + if self.path == "/api/feedback": + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + try: + data = json.loads(body) + if not isinstance(data, dict) or "reviews" not in data: + raise ValueError("Expected JSON object with 'reviews' key") + self.feedback_path.write_text(json.dumps(data, indent=2) + "\n") + resp = b'{"ok":true}' + self.send_response(200) + except (json.JSONDecodeError, OSError, ValueError) as e: + resp = json.dumps({"error": str(e)}).encode() + self.send_response(500) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(resp))) + self.end_headers() + self.wfile.write(resp) + else: + self.send_error(404) + + def log_message(self, format: str, *args: object) -> None: + # Suppress request logging to keep terminal clean + pass + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate and serve eval review") + parser.add_argument("workspace", type=Path, help="Path to workspace directory") + parser.add_argument("--port", "-p", type=int, default=3117, help="Server port (default: 3117)") + parser.add_argument("--skill-name", "-n", type=str, default=None, help="Skill name for header") + parser.add_argument( + "--previous-workspace", type=Path, default=None, + help="Path to previous iteration's workspace (shows old outputs and feedback as context)", + ) + parser.add_argument( + "--benchmark", type=Path, default=None, + help="Path to benchmark.json to show in the Benchmark tab", + ) + parser.add_argument( + "--static", "-s", type=Path, default=None, + help="Write standalone HTML to this path instead of starting a server", + ) + args = parser.parse_args() + + workspace = args.workspace.resolve() + if not workspace.is_dir(): + print(f"Error: {workspace} is not a directory", file=sys.stderr) + sys.exit(1) + + runs = find_runs(workspace) + if not runs: + print(f"No runs found in {workspace}", file=sys.stderr) + sys.exit(1) + + skill_name = args.skill_name or workspace.name.replace("-workspace", "") + feedback_path = workspace / "feedback.json" + + previous: dict[str, dict] = {} + if args.previous_workspace: + previous = load_previous_iteration(args.previous_workspace.resolve()) + + benchmark_path = args.benchmark.resolve() if args.benchmark else None + benchmark = None + if benchmark_path and benchmark_path.exists(): + try: + benchmark = json.loads(benchmark_path.read_text()) + except (json.JSONDecodeError, OSError): + pass + + if args.static: + html = generate_html(runs, skill_name, previous, benchmark) + args.static.parent.mkdir(parents=True, exist_ok=True) + args.static.write_text(html) + print(f"\n Static viewer written to: {args.static}\n") + sys.exit(0) + + # Kill any existing process on the target port + port = args.port + _kill_port(port) + handler = partial(ReviewHandler, workspace, skill_name, feedback_path, previous, benchmark_path) + try: + server = HTTPServer(("127.0.0.1", port), handler) + except OSError: + # Port still in use after kill attempt — find a free one + server = HTTPServer(("127.0.0.1", 0), handler) + port = server.server_address[1] + + url = f"http://localhost:{port}" + print(f"\n Eval Viewer") + print(f" ─────────────────────────────────") + print(f" URL: {url}") + print(f" Workspace: {workspace}") + print(f" Feedback: {feedback_path}") + if previous: + print(f" Previous: {args.previous_workspace} ({len(previous)} runs)") + if benchmark_path: + print(f" Benchmark: {benchmark_path}") + print(f"\n Press Ctrl+C to stop.\n") + + webbrowser.open(url) + + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nStopped.") + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/skill-creator/eval-viewer/viewer.html b/.agents/skills/skill-creator/eval-viewer/viewer.html new file mode 100644 index 0000000..6d8e963 --- /dev/null +++ b/.agents/skills/skill-creator/eval-viewer/viewer.html @@ -0,0 +1,1325 @@ + + + + + + Eval Review + + + + + + + +
+
+
+

Eval Review:

+
Review each output and leave feedback below. Navigate with arrow keys or buttons. When done, copy feedback and paste into Claude Code.
+
+
+
+ + + + + +
+
+ +
+
Prompt
+
+
+
+
+ + +
+
Output
+
+
No output files found
+
+
+ + + + + + + + +
+
Your Feedback
+
+ + + +
+
+
+ + +
+ + +
+
+
No benchmark data available. Run a benchmark to see quantitative results here.
+
+
+
+ + +
+
+

Review Complete

+

Your feedback has been saved. Go back to your Claude Code session and tell Claude you're done reviewing.

+
+ +
+
+
+ + +
+ + + + diff --git a/.agents/skills/skill-creator/references/schemas.md b/.agents/skills/skill-creator/references/schemas.md new file mode 100644 index 0000000..b6eeaa2 --- /dev/null +++ b/.agents/skills/skill-creator/references/schemas.md @@ -0,0 +1,430 @@ +# JSON Schemas + +This document defines the JSON schemas used by skill-creator. + +--- + +## evals.json + +Defines the evals for a skill. Located at `evals/evals.json` within the skill directory. + +```json +{ + "skill_name": "example-skill", + "evals": [ + { + "id": 1, + "prompt": "User's example prompt", + "expected_output": "Description of expected result", + "files": ["evals/files/sample1.pdf"], + "expectations": [ + "The output includes X", + "The skill used script Y" + ] + } + ] +} +``` + +**Fields:** +- `skill_name`: Name matching the skill's frontmatter +- `evals[].id`: Unique integer identifier +- `evals[].prompt`: The task to execute +- `evals[].expected_output`: Human-readable description of success +- `evals[].files`: Optional list of input file paths (relative to skill root) +- `evals[].expectations`: List of verifiable statements + +--- + +## history.json + +Tracks version progression in Improve mode. Located at workspace root. + +```json +{ + "started_at": "2026-01-15T10:30:00Z", + "skill_name": "pdf", + "current_best": "v2", + "iterations": [ + { + "version": "v0", + "parent": null, + "expectation_pass_rate": 0.65, + "grading_result": "baseline", + "is_current_best": false + }, + { + "version": "v1", + "parent": "v0", + "expectation_pass_rate": 0.75, + "grading_result": "won", + "is_current_best": false + }, + { + "version": "v2", + "parent": "v1", + "expectation_pass_rate": 0.85, + "grading_result": "won", + "is_current_best": true + } + ] +} +``` + +**Fields:** +- `started_at`: ISO timestamp of when improvement started +- `skill_name`: Name of the skill being improved +- `current_best`: Version identifier of the best performer +- `iterations[].version`: Version identifier (v0, v1, ...) +- `iterations[].parent`: Parent version this was derived from +- `iterations[].expectation_pass_rate`: Pass rate from grading +- `iterations[].grading_result`: "baseline", "won", "lost", or "tie" +- `iterations[].is_current_best`: Whether this is the current best version + +--- + +## grading.json + +Output from the grader agent. Located at `/grading.json`. + +```json +{ + "expectations": [ + { + "text": "The output includes the name 'John Smith'", + "passed": true, + "evidence": "Found in transcript Step 3: 'Extracted names: John Smith, Sarah Johnson'" + }, + { + "text": "The spreadsheet has a SUM formula in cell B10", + "passed": false, + "evidence": "No spreadsheet was created. The output was a text file." + } + ], + "summary": { + "passed": 2, + "failed": 1, + "total": 3, + "pass_rate": 0.67 + }, + "execution_metrics": { + "tool_calls": { + "Read": 5, + "Write": 2, + "Bash": 8 + }, + "total_tool_calls": 15, + "total_steps": 6, + "errors_encountered": 0, + "output_chars": 12450, + "transcript_chars": 3200 + }, + "timing": { + "executor_duration_seconds": 165.0, + "grader_duration_seconds": 26.0, + "total_duration_seconds": 191.0 + }, + "claims": [ + { + "claim": "The form has 12 fillable fields", + "type": "factual", + "verified": true, + "evidence": "Counted 12 fields in field_info.json" + } + ], + "user_notes_summary": { + "uncertainties": ["Used 2023 data, may be stale"], + "needs_review": [], + "workarounds": ["Fell back to text overlay for non-fillable fields"] + }, + "eval_feedback": { + "suggestions": [ + { + "assertion": "The output includes the name 'John Smith'", + "reason": "A hallucinated document that mentions the name would also pass" + } + ], + "overall": "Assertions check presence but not correctness." + } +} +``` + +**Fields:** +- `expectations[]`: Graded expectations with evidence +- `summary`: Aggregate pass/fail counts +- `execution_metrics`: Tool usage and output size (from executor's metrics.json) +- `timing`: Wall clock timing (from timing.json) +- `claims`: Extracted and verified claims from the output +- `user_notes_summary`: Issues flagged by the executor +- `eval_feedback`: (optional) Improvement suggestions for the evals, only present when the grader identifies issues worth raising + +--- + +## metrics.json + +Output from the executor agent. Located at `/outputs/metrics.json`. + +```json +{ + "tool_calls": { + "Read": 5, + "Write": 2, + "Bash": 8, + "Edit": 1, + "Glob": 2, + "Grep": 0 + }, + "total_tool_calls": 18, + "total_steps": 6, + "files_created": ["filled_form.pdf", "field_values.json"], + "errors_encountered": 0, + "output_chars": 12450, + "transcript_chars": 3200 +} +``` + +**Fields:** +- `tool_calls`: Count per tool type +- `total_tool_calls`: Sum of all tool calls +- `total_steps`: Number of major execution steps +- `files_created`: List of output files created +- `errors_encountered`: Number of errors during execution +- `output_chars`: Total character count of output files +- `transcript_chars`: Character count of transcript + +--- + +## timing.json + +Wall clock timing for a run. Located at `/timing.json`. + +**How to capture:** When a subagent task completes, the task notification includes `total_tokens` and `duration_ms`. Save these immediately — they are not persisted anywhere else and cannot be recovered after the fact. + +```json +{ + "total_tokens": 84852, + "duration_ms": 23332, + "total_duration_seconds": 23.3, + "executor_start": "2026-01-15T10:30:00Z", + "executor_end": "2026-01-15T10:32:45Z", + "executor_duration_seconds": 165.0, + "grader_start": "2026-01-15T10:32:46Z", + "grader_end": "2026-01-15T10:33:12Z", + "grader_duration_seconds": 26.0 +} +``` + +--- + +## benchmark.json + +Output from Benchmark mode. Located at `benchmarks//benchmark.json`. + +```json +{ + "metadata": { + "skill_name": "pdf", + "skill_path": "/path/to/pdf", + "executor_model": "claude-sonnet-4-20250514", + "analyzer_model": "most-capable-model", + "timestamp": "2026-01-15T10:30:00Z", + "evals_run": [1, 2, 3], + "runs_per_configuration": 3 + }, + + "runs": [ + { + "eval_id": 1, + "eval_name": "Ocean", + "configuration": "with_skill", + "run_number": 1, + "result": { + "pass_rate": 0.85, + "passed": 6, + "failed": 1, + "total": 7, + "time_seconds": 42.5, + "tokens": 3800, + "tool_calls": 18, + "errors": 0 + }, + "expectations": [ + {"text": "...", "passed": true, "evidence": "..."} + ], + "notes": [ + "Used 2023 data, may be stale", + "Fell back to text overlay for non-fillable fields" + ] + } + ], + + "run_summary": { + "with_skill": { + "pass_rate": {"mean": 0.85, "stddev": 0.05, "min": 0.80, "max": 0.90}, + "time_seconds": {"mean": 45.0, "stddev": 12.0, "min": 32.0, "max": 58.0}, + "tokens": {"mean": 3800, "stddev": 400, "min": 3200, "max": 4100} + }, + "without_skill": { + "pass_rate": {"mean": 0.35, "stddev": 0.08, "min": 0.28, "max": 0.45}, + "time_seconds": {"mean": 32.0, "stddev": 8.0, "min": 24.0, "max": 42.0}, + "tokens": {"mean": 2100, "stddev": 300, "min": 1800, "max": 2500} + }, + "delta": { + "pass_rate": "+0.50", + "time_seconds": "+13.0", + "tokens": "+1700" + } + }, + + "notes": [ + "Assertion 'Output is a PDF file' passes 100% in both configurations - may not differentiate skill value", + "Eval 3 shows high variance (50% ± 40%) - may be flaky or model-dependent", + "Without-skill runs consistently fail on table extraction expectations", + "Skill adds 13s average execution time but improves pass rate by 50%" + ] +} +``` + +**Fields:** +- `metadata`: Information about the benchmark run + - `skill_name`: Name of the skill + - `timestamp`: When the benchmark was run + - `evals_run`: List of eval names or IDs + - `runs_per_configuration`: Number of runs per config (e.g. 3) +- `runs[]`: Individual run results + - `eval_id`: Numeric eval identifier + - `eval_name`: Human-readable eval name (used as section header in the viewer) + - `configuration`: Must be `"with_skill"` or `"without_skill"` (the viewer uses this exact string for grouping and color coding) + - `run_number`: Integer run number (1, 2, 3...) + - `result`: Nested object with `pass_rate`, `passed`, `total`, `time_seconds`, `tokens`, `errors` +- `run_summary`: Statistical aggregates per configuration + - `with_skill` / `without_skill`: Each contains `pass_rate`, `time_seconds`, `tokens` objects with `mean` and `stddev` fields + - `delta`: Difference strings like `"+0.50"`, `"+13.0"`, `"+1700"` +- `notes`: Freeform observations from the analyzer + +**Important:** The viewer reads these field names exactly. Using `config` instead of `configuration`, or putting `pass_rate` at the top level of a run instead of nested under `result`, will cause the viewer to show empty/zero values. Always reference this schema when generating benchmark.json manually. + +--- + +## comparison.json + +Output from blind comparator. Located at `/comparison-N.json`. + +```json +{ + "winner": "A", + "reasoning": "Output A provides a complete solution with proper formatting and all required fields. Output B is missing the date field and has formatting inconsistencies.", + "rubric": { + "A": { + "content": { + "correctness": 5, + "completeness": 5, + "accuracy": 4 + }, + "structure": { + "organization": 4, + "formatting": 5, + "usability": 4 + }, + "content_score": 4.7, + "structure_score": 4.3, + "overall_score": 9.0 + }, + "B": { + "content": { + "correctness": 3, + "completeness": 2, + "accuracy": 3 + }, + "structure": { + "organization": 3, + "formatting": 2, + "usability": 3 + }, + "content_score": 2.7, + "structure_score": 2.7, + "overall_score": 5.4 + } + }, + "output_quality": { + "A": { + "score": 9, + "strengths": ["Complete solution", "Well-formatted", "All fields present"], + "weaknesses": ["Minor style inconsistency in header"] + }, + "B": { + "score": 5, + "strengths": ["Readable output", "Correct basic structure"], + "weaknesses": ["Missing date field", "Formatting inconsistencies", "Partial data extraction"] + } + }, + "expectation_results": { + "A": { + "passed": 4, + "total": 5, + "pass_rate": 0.80, + "details": [ + {"text": "Output includes name", "passed": true} + ] + }, + "B": { + "passed": 3, + "total": 5, + "pass_rate": 0.60, + "details": [ + {"text": "Output includes name", "passed": true} + ] + } + } +} +``` + +--- + +## analysis.json + +Output from post-hoc analyzer. Located at `/analysis.json`. + +```json +{ + "comparison_summary": { + "winner": "A", + "winner_skill": "path/to/winner/skill", + "loser_skill": "path/to/loser/skill", + "comparator_reasoning": "Brief summary of why comparator chose winner" + }, + "winner_strengths": [ + "Clear step-by-step instructions for handling multi-page documents", + "Included validation script that caught formatting errors" + ], + "loser_weaknesses": [ + "Vague instruction 'process the document appropriately' led to inconsistent behavior", + "No script for validation, agent had to improvise" + ], + "instruction_following": { + "winner": { + "score": 9, + "issues": ["Minor: skipped optional logging step"] + }, + "loser": { + "score": 6, + "issues": [ + "Did not use the skill's formatting template", + "Invented own approach instead of following step 3" + ] + } + }, + "improvement_suggestions": [ + { + "priority": "high", + "category": "instructions", + "suggestion": "Replace 'process the document appropriately' with explicit steps", + "expected_impact": "Would eliminate ambiguity that caused inconsistent behavior" + } + ], + "transcript_insights": { + "winner_execution_pattern": "Read skill -> Followed 5-step process -> Used validation script", + "loser_execution_pattern": "Read skill -> Unclear on approach -> Tried 3 different methods" + } +} +``` diff --git a/.agents/skills/skill-creator/scripts/__init__.py b/.agents/skills/skill-creator/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.agents/skills/skill-creator/scripts/aggregate_benchmark.py b/.agents/skills/skill-creator/scripts/aggregate_benchmark.py new file mode 100755 index 0000000..3e66e8c --- /dev/null +++ b/.agents/skills/skill-creator/scripts/aggregate_benchmark.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +""" +Aggregate individual run results into benchmark summary statistics. + +Reads grading.json files from run directories and produces: +- run_summary with mean, stddev, min, max for each metric +- delta between with_skill and without_skill configurations + +Usage: + python aggregate_benchmark.py + +Example: + python aggregate_benchmark.py benchmarks/2026-01-15T10-30-00/ + +The script supports two directory layouts: + + Workspace layout (from skill-creator iterations): + / + └── eval-N/ + ├── with_skill/ + │ ├── run-1/grading.json + │ └── run-2/grading.json + └── without_skill/ + ├── run-1/grading.json + └── run-2/grading.json + + Legacy layout (with runs/ subdirectory): + / + └── runs/ + └── eval-N/ + ├── with_skill/ + │ └── run-1/grading.json + └── without_skill/ + └── run-1/grading.json +""" + +import argparse +import json +import math +import sys +from datetime import datetime, timezone +from pathlib import Path + + +def calculate_stats(values: list[float]) -> dict: + """Calculate mean, stddev, min, max for a list of values.""" + if not values: + return {"mean": 0.0, "stddev": 0.0, "min": 0.0, "max": 0.0} + + n = len(values) + mean = sum(values) / n + + if n > 1: + variance = sum((x - mean) ** 2 for x in values) / (n - 1) + stddev = math.sqrt(variance) + else: + stddev = 0.0 + + return { + "mean": round(mean, 4), + "stddev": round(stddev, 4), + "min": round(min(values), 4), + "max": round(max(values), 4) + } + + +def load_run_results(benchmark_dir: Path) -> dict: + """ + Load all run results from a benchmark directory. + + Returns dict keyed by config name (e.g. "with_skill"/"without_skill", + or "new_skill"/"old_skill"), each containing a list of run results. + """ + # Support both layouts: eval dirs directly under benchmark_dir, or under runs/ + runs_dir = benchmark_dir / "runs" + if runs_dir.exists(): + search_dir = runs_dir + elif list(benchmark_dir.glob("eval-*")): + search_dir = benchmark_dir + else: + print(f"No eval directories found in {benchmark_dir} or {benchmark_dir / 'runs'}") + return {} + + results: dict[str, list] = {} + + for eval_idx, eval_dir in enumerate(sorted(search_dir.glob("eval-*"))): + metadata_path = eval_dir / "eval_metadata.json" + if metadata_path.exists(): + try: + with open(metadata_path) as mf: + eval_id = json.load(mf).get("eval_id", eval_idx) + except (json.JSONDecodeError, OSError): + eval_id = eval_idx + else: + try: + eval_id = int(eval_dir.name.split("-")[1]) + except ValueError: + eval_id = eval_idx + + # Discover config directories dynamically rather than hardcoding names + for config_dir in sorted(eval_dir.iterdir()): + if not config_dir.is_dir(): + continue + # Skip non-config directories (inputs, outputs, etc.) + if not list(config_dir.glob("run-*")): + continue + config = config_dir.name + if config not in results: + results[config] = [] + + for run_dir in sorted(config_dir.glob("run-*")): + run_number = int(run_dir.name.split("-")[1]) + grading_file = run_dir / "grading.json" + + if not grading_file.exists(): + print(f"Warning: grading.json not found in {run_dir}") + continue + + try: + with open(grading_file) as f: + grading = json.load(f) + except json.JSONDecodeError as e: + print(f"Warning: Invalid JSON in {grading_file}: {e}") + continue + + # Extract metrics + result = { + "eval_id": eval_id, + "run_number": run_number, + "pass_rate": grading.get("summary", {}).get("pass_rate", 0.0), + "passed": grading.get("summary", {}).get("passed", 0), + "failed": grading.get("summary", {}).get("failed", 0), + "total": grading.get("summary", {}).get("total", 0), + } + + # Extract timing — check grading.json first, then sibling timing.json + timing = grading.get("timing", {}) + result["time_seconds"] = timing.get("total_duration_seconds", 0.0) + timing_file = run_dir / "timing.json" + if result["time_seconds"] == 0.0 and timing_file.exists(): + try: + with open(timing_file) as tf: + timing_data = json.load(tf) + result["time_seconds"] = timing_data.get("total_duration_seconds", 0.0) + result["tokens"] = timing_data.get("total_tokens", 0) + except json.JSONDecodeError: + pass + + # Extract metrics if available + metrics = grading.get("execution_metrics", {}) + result["tool_calls"] = metrics.get("total_tool_calls", 0) + if not result.get("tokens"): + result["tokens"] = metrics.get("output_chars", 0) + result["errors"] = metrics.get("errors_encountered", 0) + + # Extract expectations — viewer requires fields: text, passed, evidence + raw_expectations = grading.get("expectations", []) + for exp in raw_expectations: + if "text" not in exp or "passed" not in exp: + print(f"Warning: expectation in {grading_file} missing required fields (text, passed, evidence): {exp}") + result["expectations"] = raw_expectations + + # Extract notes from user_notes_summary + notes_summary = grading.get("user_notes_summary", {}) + notes = [] + notes.extend(notes_summary.get("uncertainties", [])) + notes.extend(notes_summary.get("needs_review", [])) + notes.extend(notes_summary.get("workarounds", [])) + result["notes"] = notes + + results[config].append(result) + + return results + + +def aggregate_results(results: dict) -> dict: + """ + Aggregate run results into summary statistics. + + Returns run_summary with stats for each configuration and delta. + """ + run_summary = {} + configs = list(results.keys()) + + for config in configs: + runs = results.get(config, []) + + if not runs: + run_summary[config] = { + "pass_rate": {"mean": 0.0, "stddev": 0.0, "min": 0.0, "max": 0.0}, + "time_seconds": {"mean": 0.0, "stddev": 0.0, "min": 0.0, "max": 0.0}, + "tokens": {"mean": 0, "stddev": 0, "min": 0, "max": 0} + } + continue + + pass_rates = [r["pass_rate"] for r in runs] + times = [r["time_seconds"] for r in runs] + tokens = [r.get("tokens", 0) for r in runs] + + run_summary[config] = { + "pass_rate": calculate_stats(pass_rates), + "time_seconds": calculate_stats(times), + "tokens": calculate_stats(tokens) + } + + # Calculate delta between the first two configs (if two exist) + if len(configs) >= 2: + primary = run_summary.get(configs[0], {}) + baseline = run_summary.get(configs[1], {}) + else: + primary = run_summary.get(configs[0], {}) if configs else {} + baseline = {} + + delta_pass_rate = primary.get("pass_rate", {}).get("mean", 0) - baseline.get("pass_rate", {}).get("mean", 0) + delta_time = primary.get("time_seconds", {}).get("mean", 0) - baseline.get("time_seconds", {}).get("mean", 0) + delta_tokens = primary.get("tokens", {}).get("mean", 0) - baseline.get("tokens", {}).get("mean", 0) + + run_summary["delta"] = { + "pass_rate": f"{delta_pass_rate:+.2f}", + "time_seconds": f"{delta_time:+.1f}", + "tokens": f"{delta_tokens:+.0f}" + } + + return run_summary + + +def generate_benchmark(benchmark_dir: Path, skill_name: str = "", skill_path: str = "") -> dict: + """ + Generate complete benchmark.json from run results. + """ + results = load_run_results(benchmark_dir) + run_summary = aggregate_results(results) + + # Build runs array for benchmark.json + runs = [] + for config in results: + for result in results[config]: + runs.append({ + "eval_id": result["eval_id"], + "configuration": config, + "run_number": result["run_number"], + "result": { + "pass_rate": result["pass_rate"], + "passed": result["passed"], + "failed": result["failed"], + "total": result["total"], + "time_seconds": result["time_seconds"], + "tokens": result.get("tokens", 0), + "tool_calls": result.get("tool_calls", 0), + "errors": result.get("errors", 0) + }, + "expectations": result["expectations"], + "notes": result["notes"] + }) + + # Determine eval IDs from results + eval_ids = sorted(set( + r["eval_id"] + for config in results.values() + for r in config + )) + + benchmark = { + "metadata": { + "skill_name": skill_name or "", + "skill_path": skill_path or "", + "executor_model": "", + "analyzer_model": "", + "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "evals_run": eval_ids, + "runs_per_configuration": 3 + }, + "runs": runs, + "run_summary": run_summary, + "notes": [] # To be filled by analyzer + } + + return benchmark + + +def generate_markdown(benchmark: dict) -> str: + """Generate human-readable benchmark.md from benchmark data.""" + metadata = benchmark["metadata"] + run_summary = benchmark["run_summary"] + + # Determine config names (excluding "delta") + configs = [k for k in run_summary if k != "delta"] + config_a = configs[0] if len(configs) >= 1 else "config_a" + config_b = configs[1] if len(configs) >= 2 else "config_b" + label_a = config_a.replace("_", " ").title() + label_b = config_b.replace("_", " ").title() + + lines = [ + f"# Skill Benchmark: {metadata['skill_name']}", + "", + f"**Model**: {metadata['executor_model']}", + f"**Date**: {metadata['timestamp']}", + f"**Evals**: {', '.join(map(str, metadata['evals_run']))} ({metadata['runs_per_configuration']} runs each per configuration)", + "", + "## Summary", + "", + f"| Metric | {label_a} | {label_b} | Delta |", + "|--------|------------|---------------|-------|", + ] + + a_summary = run_summary.get(config_a, {}) + b_summary = run_summary.get(config_b, {}) + delta = run_summary.get("delta", {}) + + # Format pass rate + a_pr = a_summary.get("pass_rate", {}) + b_pr = b_summary.get("pass_rate", {}) + lines.append(f"| Pass Rate | {a_pr.get('mean', 0)*100:.0f}% ± {a_pr.get('stddev', 0)*100:.0f}% | {b_pr.get('mean', 0)*100:.0f}% ± {b_pr.get('stddev', 0)*100:.0f}% | {delta.get('pass_rate', '—')} |") + + # Format time + a_time = a_summary.get("time_seconds", {}) + b_time = b_summary.get("time_seconds", {}) + lines.append(f"| Time | {a_time.get('mean', 0):.1f}s ± {a_time.get('stddev', 0):.1f}s | {b_time.get('mean', 0):.1f}s ± {b_time.get('stddev', 0):.1f}s | {delta.get('time_seconds', '—')}s |") + + # Format tokens + a_tokens = a_summary.get("tokens", {}) + b_tokens = b_summary.get("tokens", {}) + lines.append(f"| Tokens | {a_tokens.get('mean', 0):.0f} ± {a_tokens.get('stddev', 0):.0f} | {b_tokens.get('mean', 0):.0f} ± {b_tokens.get('stddev', 0):.0f} | {delta.get('tokens', '—')} |") + + # Notes section + if benchmark.get("notes"): + lines.extend([ + "", + "## Notes", + "" + ]) + for note in benchmark["notes"]: + lines.append(f"- {note}") + + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser( + description="Aggregate benchmark run results into summary statistics" + ) + parser.add_argument( + "benchmark_dir", + type=Path, + help="Path to the benchmark directory" + ) + parser.add_argument( + "--skill-name", + default="", + help="Name of the skill being benchmarked" + ) + parser.add_argument( + "--skill-path", + default="", + help="Path to the skill being benchmarked" + ) + parser.add_argument( + "--output", "-o", + type=Path, + help="Output path for benchmark.json (default: /benchmark.json)" + ) + + args = parser.parse_args() + + if not args.benchmark_dir.exists(): + print(f"Directory not found: {args.benchmark_dir}") + sys.exit(1) + + # Generate benchmark + benchmark = generate_benchmark(args.benchmark_dir, args.skill_name, args.skill_path) + + # Determine output paths + output_json = args.output or (args.benchmark_dir / "benchmark.json") + output_md = output_json.with_suffix(".md") + + # Write benchmark.json + with open(output_json, "w") as f: + json.dump(benchmark, f, indent=2) + print(f"Generated: {output_json}") + + # Write benchmark.md + markdown = generate_markdown(benchmark) + with open(output_md, "w") as f: + f.write(markdown) + print(f"Generated: {output_md}") + + # Print summary + run_summary = benchmark["run_summary"] + configs = [k for k in run_summary if k != "delta"] + delta = run_summary.get("delta", {}) + + print(f"\nSummary:") + for config in configs: + pr = run_summary[config]["pass_rate"]["mean"] + label = config.replace("_", " ").title() + print(f" {label}: {pr*100:.1f}% pass rate") + print(f" Delta: {delta.get('pass_rate', '—')}") + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/skill-creator/scripts/generate_report.py b/.agents/skills/skill-creator/scripts/generate_report.py new file mode 100755 index 0000000..959e30a --- /dev/null +++ b/.agents/skills/skill-creator/scripts/generate_report.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +"""Generate an HTML report from run_loop.py output. + +Takes the JSON output from run_loop.py and generates a visual HTML report +showing each description attempt with check/x for each test case. +Distinguishes between train and test queries. +""" + +import argparse +import html +import json +import sys +from pathlib import Path + + +def generate_html(data: dict, auto_refresh: bool = False, skill_name: str = "") -> str: + """Generate HTML report from loop output data. If auto_refresh is True, adds a meta refresh tag.""" + history = data.get("history", []) + holdout = data.get("holdout", 0) + title_prefix = html.escape(skill_name + " \u2014 ") if skill_name else "" + + # Get all unique queries from train and test sets, with should_trigger info + train_queries: list[dict] = [] + test_queries: list[dict] = [] + if history: + for r in history[0].get("train_results", history[0].get("results", [])): + train_queries.append({"query": r["query"], "should_trigger": r.get("should_trigger", True)}) + if history[0].get("test_results"): + for r in history[0].get("test_results", []): + test_queries.append({"query": r["query"], "should_trigger": r.get("should_trigger", True)}) + + refresh_tag = ' \n' if auto_refresh else "" + + html_parts = [""" + + + +""" + refresh_tag + """ """ + title_prefix + """Skill Description Optimization + + + + + + +

""" + title_prefix + """Skill Description Optimization

+
+ Optimizing your skill's description. This page updates automatically as Claude tests different versions of your skill's description. Each row is an iteration — a new description attempt. The columns show test queries: green checkmarks mean the skill triggered correctly (or correctly didn't trigger), red crosses mean it got it wrong. The "Train" score shows performance on queries used to improve the description; the "Test" score shows performance on held-out queries the optimizer hasn't seen. When it's done, Claude will apply the best-performing description to your skill. +
+"""] + + # Summary section + best_test_score = data.get('best_test_score') + best_train_score = data.get('best_train_score') + html_parts.append(f""" +
+

Original: {html.escape(data.get('original_description', 'N/A'))}

+

Best: {html.escape(data.get('best_description', 'N/A'))}

+

Best Score: {data.get('best_score', 'N/A')} {'(test)' if best_test_score else '(train)'}

+

Iterations: {data.get('iterations_run', 0)} | Train: {data.get('train_size', '?')} | Test: {data.get('test_size', '?')}

+
+""") + + # Legend + html_parts.append(""" +
+ Query columns: + Should trigger + Should NOT trigger + Train + Test +
+""") + + # Table header + html_parts.append(""" +
+ + + + + + + +""") + + # Add column headers for train queries + for qinfo in train_queries: + polarity = "positive-col" if qinfo["should_trigger"] else "negative-col" + html_parts.append(f' \n') + + # Add column headers for test queries (different color) + for qinfo in test_queries: + polarity = "positive-col" if qinfo["should_trigger"] else "negative-col" + html_parts.append(f' \n') + + html_parts.append(""" + + +""") + + # Find best iteration for highlighting + if test_queries: + best_iter = max(history, key=lambda h: h.get("test_passed") or 0).get("iteration") + else: + best_iter = max(history, key=lambda h: h.get("train_passed", h.get("passed", 0))).get("iteration") + + # Add rows for each iteration + for h in history: + iteration = h.get("iteration", "?") + train_passed = h.get("train_passed", h.get("passed", 0)) + train_total = h.get("train_total", h.get("total", 0)) + test_passed = h.get("test_passed") + test_total = h.get("test_total") + description = h.get("description", "") + train_results = h.get("train_results", h.get("results", [])) + test_results = h.get("test_results", []) + + # Create lookups for results by query + train_by_query = {r["query"]: r for r in train_results} + test_by_query = {r["query"]: r for r in test_results} if test_results else {} + + # Compute aggregate correct/total runs across all retries + def aggregate_runs(results: list[dict]) -> tuple[int, int]: + correct = 0 + total = 0 + for r in results: + runs = r.get("runs", 0) + triggers = r.get("triggers", 0) + total += runs + if r.get("should_trigger", True): + correct += triggers + else: + correct += runs - triggers + return correct, total + + train_correct, train_runs = aggregate_runs(train_results) + test_correct, test_runs = aggregate_runs(test_results) + + # Determine score classes + def score_class(correct: int, total: int) -> str: + if total > 0: + ratio = correct / total + if ratio >= 0.8: + return "score-good" + elif ratio >= 0.5: + return "score-ok" + return "score-bad" + + train_class = score_class(train_correct, train_runs) + test_class = score_class(test_correct, test_runs) + + row_class = "best-row" if iteration == best_iter else "" + + html_parts.append(f""" + + + + +""") + + # Add result for each train query + for qinfo in train_queries: + r = train_by_query.get(qinfo["query"], {}) + did_pass = r.get("pass", False) + triggers = r.get("triggers", 0) + runs = r.get("runs", 0) + + icon = "✓" if did_pass else "✗" + css_class = "pass" if did_pass else "fail" + + html_parts.append(f' \n') + + # Add result for each test query (with different background) + for qinfo in test_queries: + r = test_by_query.get(qinfo["query"], {}) + did_pass = r.get("pass", False) + triggers = r.get("triggers", 0) + runs = r.get("runs", 0) + + icon = "✓" if did_pass else "✗" + css_class = "pass" if did_pass else "fail" + + html_parts.append(f' \n') + + html_parts.append(" \n") + + html_parts.append(""" +
IterTrainTestDescription{html.escape(qinfo["query"])}{html.escape(qinfo["query"])}
{iteration}{train_correct}/{train_runs}{test_correct}/{test_runs}{html.escape(description)}{icon}{triggers}/{runs}{icon}{triggers}/{runs}
+
+""") + + html_parts.append(""" + + +""") + + return "".join(html_parts) + + +def main(): + parser = argparse.ArgumentParser(description="Generate HTML report from run_loop output") + parser.add_argument("input", help="Path to JSON output from run_loop.py (or - for stdin)") + parser.add_argument("-o", "--output", default=None, help="Output HTML file (default: stdout)") + parser.add_argument("--skill-name", default="", help="Skill name to include in the report title") + args = parser.parse_args() + + if args.input == "-": + data = json.load(sys.stdin) + else: + data = json.loads(Path(args.input).read_text()) + + html_output = generate_html(data, skill_name=args.skill_name) + + if args.output: + Path(args.output).write_text(html_output) + print(f"Report written to {args.output}", file=sys.stderr) + else: + print(html_output) + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/skill-creator/scripts/improve_description.py b/.agents/skills/skill-creator/scripts/improve_description.py new file mode 100755 index 0000000..06bcec7 --- /dev/null +++ b/.agents/skills/skill-creator/scripts/improve_description.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""Improve a skill description based on eval results. + +Takes eval results (from run_eval.py) and generates an improved description +by calling `claude -p` as a subprocess (same auth pattern as run_eval.py — +uses the session's Claude Code auth, no separate ANTHROPIC_API_KEY needed). +""" + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +from scripts.utils import parse_skill_md + + +def _call_claude(prompt: str, model: str | None, timeout: int = 300) -> str: + """Run `claude -p` with the prompt on stdin and return the text response. + + Prompt goes over stdin (not argv) because it embeds the full SKILL.md + body and can easily exceed comfortable argv length. + """ + cmd = ["claude", "-p", "--output-format", "text"] + if model: + cmd.extend(["--model", model]) + + # Remove CLAUDECODE env var to allow nesting claude -p inside a + # Claude Code session. The guard is for interactive terminal conflicts; + # programmatic subprocess usage is safe. Same pattern as run_eval.py. + env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE"} + + result = subprocess.run( + cmd, + input=prompt, + capture_output=True, + text=True, + env=env, + timeout=timeout, + ) + if result.returncode != 0: + raise RuntimeError( + f"claude -p exited {result.returncode}\nstderr: {result.stderr}" + ) + return result.stdout + + +def improve_description( + skill_name: str, + skill_content: str, + current_description: str, + eval_results: dict, + history: list[dict], + model: str, + test_results: dict | None = None, + log_dir: Path | None = None, + iteration: int | None = None, +) -> str: + """Call Claude to improve the description based on eval results.""" + failed_triggers = [ + r for r in eval_results["results"] + if r["should_trigger"] and not r["pass"] + ] + false_triggers = [ + r for r in eval_results["results"] + if not r["should_trigger"] and not r["pass"] + ] + + # Build scores summary + train_score = f"{eval_results['summary']['passed']}/{eval_results['summary']['total']}" + if test_results: + test_score = f"{test_results['summary']['passed']}/{test_results['summary']['total']}" + scores_summary = f"Train: {train_score}, Test: {test_score}" + else: + scores_summary = f"Train: {train_score}" + + prompt = f"""You are optimizing a skill description for a Claude Code skill called "{skill_name}". A "skill" is sort of like a prompt, but with progressive disclosure -- there's a title and description that Claude sees when deciding whether to use the skill, and then if it does use the skill, it reads the .md file which has lots more details and potentially links to other resources in the skill folder like helper files and scripts and additional documentation or examples. + +The description appears in Claude's "available_skills" list. When a user sends a query, Claude decides whether to invoke the skill based solely on the title and on this description. Your goal is to write a description that triggers for relevant queries, and doesn't trigger for irrelevant ones. + +Here's the current description: + +"{current_description}" + + +Current scores ({scores_summary}): + +""" + if failed_triggers: + prompt += "FAILED TO TRIGGER (should have triggered but didn't):\n" + for r in failed_triggers: + prompt += f' - "{r["query"]}" (triggered {r["triggers"]}/{r["runs"]} times)\n' + prompt += "\n" + + if false_triggers: + prompt += "FALSE TRIGGERS (triggered but shouldn't have):\n" + for r in false_triggers: + prompt += f' - "{r["query"]}" (triggered {r["triggers"]}/{r["runs"]} times)\n' + prompt += "\n" + + if history: + prompt += "PREVIOUS ATTEMPTS (do NOT repeat these — try something structurally different):\n\n" + for h in history: + train_s = f"{h.get('train_passed', h.get('passed', 0))}/{h.get('train_total', h.get('total', 0))}" + test_s = f"{h.get('test_passed', '?')}/{h.get('test_total', '?')}" if h.get('test_passed') is not None else None + score_str = f"train={train_s}" + (f", test={test_s}" if test_s else "") + prompt += f'\n' + prompt += f'Description: "{h["description"]}"\n' + if "results" in h: + prompt += "Train results:\n" + for r in h["results"]: + status = "PASS" if r["pass"] else "FAIL" + prompt += f' [{status}] "{r["query"][:80]}" (triggered {r["triggers"]}/{r["runs"]})\n' + if h.get("note"): + prompt += f'Note: {h["note"]}\n' + prompt += "\n\n" + + prompt += f""" + +Skill content (for context on what the skill does): + +{skill_content} + + +Based on the failures, write a new and improved description that is more likely to trigger correctly. When I say "based on the failures", it's a bit of a tricky line to walk because we don't want to overfit to the specific cases you're seeing. So what I DON'T want you to do is produce an ever-expanding list of specific queries that this skill should or shouldn't trigger for. Instead, try to generalize from the failures to broader categories of user intent and situations where this skill would be useful or not useful. The reason for this is twofold: + +1. Avoid overfitting +2. The list might get loooong and it's injected into ALL queries and there might be a lot of skills, so we don't want to blow too much space on any given description. + +Concretely, your description should not be more than about 100-200 words, even if that comes at the cost of accuracy. There is a hard limit of 1024 characters — descriptions over that will be truncated, so stay comfortably under it. + +Here are some tips that we've found to work well in writing these descriptions: +- The skill should be phrased in the imperative -- "Use this skill for" rather than "this skill does" +- The skill description should focus on the user's intent, what they are trying to achieve, vs. the implementation details of how the skill works. +- The description competes with other skills for Claude's attention — make it distinctive and immediately recognizable. +- If you're getting lots of failures after repeated attempts, change things up. Try different sentence structures or wordings. + +I'd encourage you to be creative and mix up the style in different iterations since you'll have multiple opportunities to try different approaches and we'll just grab the highest-scoring one at the end. + +Please respond with only the new description text in tags, nothing else.""" + + text = _call_claude(prompt, model) + + match = re.search(r"(.*?)", text, re.DOTALL) + description = match.group(1).strip().strip('"') if match else text.strip().strip('"') + + transcript: dict = { + "iteration": iteration, + "prompt": prompt, + "response": text, + "parsed_description": description, + "char_count": len(description), + "over_limit": len(description) > 1024, + } + + # Safety net: the prompt already states the 1024-char hard limit, but if + # the model blew past it anyway, make one fresh single-turn call that + # quotes the too-long version and asks for a shorter rewrite. (The old + # SDK path did this as a true multi-turn; `claude -p` is one-shot, so we + # inline the prior output into the new prompt instead.) + if len(description) > 1024: + shorten_prompt = ( + f"{prompt}\n\n" + f"---\n\n" + f"A previous attempt produced this description, which at " + f"{len(description)} characters is over the 1024-character hard limit:\n\n" + f'"{description}"\n\n' + f"Rewrite it to be under 1024 characters while keeping the most " + f"important trigger words and intent coverage. Respond with only " + f"the new description in tags." + ) + shorten_text = _call_claude(shorten_prompt, model) + match = re.search(r"(.*?)", shorten_text, re.DOTALL) + shortened = match.group(1).strip().strip('"') if match else shorten_text.strip().strip('"') + + transcript["rewrite_prompt"] = shorten_prompt + transcript["rewrite_response"] = shorten_text + transcript["rewrite_description"] = shortened + transcript["rewrite_char_count"] = len(shortened) + description = shortened + + transcript["final_description"] = description + + if log_dir: + log_dir.mkdir(parents=True, exist_ok=True) + log_file = log_dir / f"improve_iter_{iteration or 'unknown'}.json" + log_file.write_text(json.dumps(transcript, indent=2)) + + return description + + +def main(): + parser = argparse.ArgumentParser(description="Improve a skill description based on eval results") + parser.add_argument("--eval-results", required=True, help="Path to eval results JSON (from run_eval.py)") + parser.add_argument("--skill-path", required=True, help="Path to skill directory") + parser.add_argument("--history", default=None, help="Path to history JSON (previous attempts)") + parser.add_argument("--model", required=True, help="Model for improvement") + parser.add_argument("--verbose", action="store_true", help="Print thinking to stderr") + args = parser.parse_args() + + skill_path = Path(args.skill_path) + if not (skill_path / "SKILL.md").exists(): + print(f"Error: No SKILL.md found at {skill_path}", file=sys.stderr) + sys.exit(1) + + eval_results = json.loads(Path(args.eval_results).read_text()) + history = [] + if args.history: + history = json.loads(Path(args.history).read_text()) + + name, _, content = parse_skill_md(skill_path) + current_description = eval_results["description"] + + if args.verbose: + print(f"Current: {current_description}", file=sys.stderr) + print(f"Score: {eval_results['summary']['passed']}/{eval_results['summary']['total']}", file=sys.stderr) + + new_description = improve_description( + skill_name=name, + skill_content=content, + current_description=current_description, + eval_results=eval_results, + history=history, + model=args.model, + ) + + if args.verbose: + print(f"Improved: {new_description}", file=sys.stderr) + + # Output as JSON with both the new description and updated history + output = { + "description": new_description, + "history": history + [{ + "description": current_description, + "passed": eval_results["summary"]["passed"], + "failed": eval_results["summary"]["failed"], + "total": eval_results["summary"]["total"], + "results": eval_results["results"], + }], + } + print(json.dumps(output, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/skill-creator/scripts/package_skill.py b/.agents/skills/skill-creator/scripts/package_skill.py new file mode 100755 index 0000000..f48eac4 --- /dev/null +++ b/.agents/skills/skill-creator/scripts/package_skill.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +""" +Skill Packager - Creates a distributable .skill file of a skill folder + +Usage: + python utils/package_skill.py [output-directory] + +Example: + python utils/package_skill.py skills/public/my-skill + python utils/package_skill.py skills/public/my-skill ./dist +""" + +import fnmatch +import sys +import zipfile +from pathlib import Path +from scripts.quick_validate import validate_skill + +# Patterns to exclude when packaging skills. +EXCLUDE_DIRS = {"__pycache__", "node_modules"} +EXCLUDE_GLOBS = {"*.pyc"} +EXCLUDE_FILES = {".DS_Store"} +# Directories excluded only at the skill root (not when nested deeper). +ROOT_EXCLUDE_DIRS = {"evals"} + + +def should_exclude(rel_path: Path) -> bool: + """Check if a path should be excluded from packaging.""" + parts = rel_path.parts + if any(part in EXCLUDE_DIRS for part in parts): + return True + # rel_path is relative to skill_path.parent, so parts[0] is the skill + # folder name and parts[1] (if present) is the first subdir. + if len(parts) > 1 and parts[1] in ROOT_EXCLUDE_DIRS: + return True + name = rel_path.name + if name in EXCLUDE_FILES: + return True + return any(fnmatch.fnmatch(name, pat) for pat in EXCLUDE_GLOBS) + + +def package_skill(skill_path, output_dir=None): + """ + Package a skill folder into a .skill file. + + Args: + skill_path: Path to the skill folder + output_dir: Optional output directory for the .skill file (defaults to current directory) + + Returns: + Path to the created .skill file, or None if error + """ + skill_path = Path(skill_path).resolve() + + # Validate skill folder exists + if not skill_path.exists(): + print(f"❌ Error: Skill folder not found: {skill_path}") + return None + + if not skill_path.is_dir(): + print(f"❌ Error: Path is not a directory: {skill_path}") + return None + + # Validate SKILL.md exists + skill_md = skill_path / "SKILL.md" + if not skill_md.exists(): + print(f"❌ Error: SKILL.md not found in {skill_path}") + return None + + # Run validation before packaging + print("🔍 Validating skill...") + valid, message = validate_skill(skill_path) + if not valid: + print(f"❌ Validation failed: {message}") + print(" Please fix the validation errors before packaging.") + return None + print(f"✅ {message}\n") + + # Determine output location + skill_name = skill_path.name + if output_dir: + output_path = Path(output_dir).resolve() + output_path.mkdir(parents=True, exist_ok=True) + else: + output_path = Path.cwd() + + skill_filename = output_path / f"{skill_name}.skill" + + # Create the .skill file (zip format) + try: + with zipfile.ZipFile(skill_filename, 'w', zipfile.ZIP_DEFLATED) as zipf: + # Walk through the skill directory, excluding build artifacts + for file_path in skill_path.rglob('*'): + if not file_path.is_file(): + continue + arcname = file_path.relative_to(skill_path.parent) + if should_exclude(arcname): + print(f" Skipped: {arcname}") + continue + zipf.write(file_path, arcname) + print(f" Added: {arcname}") + + print(f"\n✅ Successfully packaged skill to: {skill_filename}") + return skill_filename + + except Exception as e: + print(f"❌ Error creating .skill file: {e}") + return None + + +def main(): + if len(sys.argv) < 2: + print("Usage: python utils/package_skill.py [output-directory]") + print("\nExample:") + print(" python utils/package_skill.py skills/public/my-skill") + print(" python utils/package_skill.py skills/public/my-skill ./dist") + sys.exit(1) + + skill_path = sys.argv[1] + output_dir = sys.argv[2] if len(sys.argv) > 2 else None + + print(f"📦 Packaging skill: {skill_path}") + if output_dir: + print(f" Output directory: {output_dir}") + print() + + result = package_skill(skill_path, output_dir) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/skill-creator/scripts/quick_validate.py b/.agents/skills/skill-creator/scripts/quick_validate.py new file mode 100755 index 0000000..ed8e1dd --- /dev/null +++ b/.agents/skills/skill-creator/scripts/quick_validate.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +Quick validation script for skills - minimal version +""" + +import sys +import os +import re +import yaml +from pathlib import Path + +def validate_skill(skill_path): + """Basic validation of a skill""" + skill_path = Path(skill_path) + + # Check SKILL.md exists + skill_md = skill_path / 'SKILL.md' + if not skill_md.exists(): + return False, "SKILL.md not found" + + # Read and validate frontmatter + content = skill_md.read_text() + if not content.startswith('---'): + return False, "No YAML frontmatter found" + + # Extract frontmatter + match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) + if not match: + return False, "Invalid frontmatter format" + + frontmatter_text = match.group(1) + + # Parse YAML frontmatter + try: + frontmatter = yaml.safe_load(frontmatter_text) + if not isinstance(frontmatter, dict): + return False, "Frontmatter must be a YAML dictionary" + except yaml.YAMLError as e: + return False, f"Invalid YAML in frontmatter: {e}" + + # Define allowed properties + ALLOWED_PROPERTIES = {'name', 'description', 'license', 'allowed-tools', 'metadata', 'compatibility'} + + # Check for unexpected properties (excluding nested keys under metadata) + unexpected_keys = set(frontmatter.keys()) - ALLOWED_PROPERTIES + if unexpected_keys: + return False, ( + f"Unexpected key(s) in SKILL.md frontmatter: {', '.join(sorted(unexpected_keys))}. " + f"Allowed properties are: {', '.join(sorted(ALLOWED_PROPERTIES))}" + ) + + # Check required fields + if 'name' not in frontmatter: + return False, "Missing 'name' in frontmatter" + if 'description' not in frontmatter: + return False, "Missing 'description' in frontmatter" + + # Extract name for validation + name = frontmatter.get('name', '') + if not isinstance(name, str): + return False, f"Name must be a string, got {type(name).__name__}" + name = name.strip() + if name: + # Check naming convention (kebab-case: lowercase with hyphens) + if not re.match(r'^[a-z0-9-]+$', name): + return False, f"Name '{name}' should be kebab-case (lowercase letters, digits, and hyphens only)" + if name.startswith('-') or name.endswith('-') or '--' in name: + return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens" + # Check name length (max 64 characters per spec) + if len(name) > 64: + return False, f"Name is too long ({len(name)} characters). Maximum is 64 characters." + + # Extract and validate description + description = frontmatter.get('description', '') + if not isinstance(description, str): + return False, f"Description must be a string, got {type(description).__name__}" + description = description.strip() + if description: + # Check for angle brackets + if '<' in description or '>' in description: + return False, "Description cannot contain angle brackets (< or >)" + # Check description length (max 1024 characters per spec) + if len(description) > 1024: + return False, f"Description is too long ({len(description)} characters). Maximum is 1024 characters." + + # Validate compatibility field if present (optional) + compatibility = frontmatter.get('compatibility', '') + if compatibility: + if not isinstance(compatibility, str): + return False, f"Compatibility must be a string, got {type(compatibility).__name__}" + if len(compatibility) > 500: + return False, f"Compatibility is too long ({len(compatibility)} characters). Maximum is 500 characters." + + return True, "Skill is valid!" + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python quick_validate.py ") + sys.exit(1) + + valid, message = validate_skill(sys.argv[1]) + print(message) + sys.exit(0 if valid else 1) \ No newline at end of file diff --git a/.agents/skills/skill-creator/scripts/run_eval.py b/.agents/skills/skill-creator/scripts/run_eval.py new file mode 100755 index 0000000..e58c70b --- /dev/null +++ b/.agents/skills/skill-creator/scripts/run_eval.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +"""Run trigger evaluation for a skill description. + +Tests whether a skill's description causes Claude to trigger (read the skill) +for a set of queries. Outputs results as JSON. +""" + +import argparse +import json +import os +import select +import subprocess +import sys +import time +import uuid +from concurrent.futures import ProcessPoolExecutor, as_completed +from pathlib import Path + +from scripts.utils import parse_skill_md + + +def find_project_root() -> Path: + """Find the project root by walking up from cwd looking for .claude/. + + Mimics how Claude Code discovers its project root, so the command file + we create ends up where claude -p will look for it. + """ + current = Path.cwd() + for parent in [current, *current.parents]: + if (parent / ".claude").is_dir(): + return parent + return current + + +def run_single_query( + query: str, + skill_name: str, + skill_description: str, + timeout: int, + project_root: str, + model: str | None = None, +) -> bool: + """Run a single query and return whether the skill was triggered. + + Creates a command file in .claude/commands/ so it appears in Claude's + available_skills list, then runs `claude -p` with the raw query. + Uses --include-partial-messages to detect triggering early from + stream events (content_block_start) rather than waiting for the + full assistant message, which only arrives after tool execution. + """ + unique_id = uuid.uuid4().hex[:8] + clean_name = f"{skill_name}-skill-{unique_id}" + project_commands_dir = Path(project_root) / ".claude" / "commands" + command_file = project_commands_dir / f"{clean_name}.md" + + try: + project_commands_dir.mkdir(parents=True, exist_ok=True) + # Use YAML block scalar to avoid breaking on quotes in description + indented_desc = "\n ".join(skill_description.split("\n")) + command_content = ( + f"---\n" + f"description: |\n" + f" {indented_desc}\n" + f"---\n\n" + f"# {skill_name}\n\n" + f"This skill handles: {skill_description}\n" + ) + command_file.write_text(command_content) + + cmd = [ + "claude", + "-p", query, + "--output-format", "stream-json", + "--verbose", + "--include-partial-messages", + ] + if model: + cmd.extend(["--model", model]) + + # Remove CLAUDECODE env var to allow nesting claude -p inside a + # Claude Code session. The guard is for interactive terminal conflicts; + # programmatic subprocess usage is safe. + env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE"} + + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + cwd=project_root, + env=env, + ) + + triggered = False + start_time = time.time() + buffer = "" + # Track state for stream event detection + pending_tool_name = None + accumulated_json = "" + + try: + while time.time() - start_time < timeout: + if process.poll() is not None: + remaining = process.stdout.read() + if remaining: + buffer += remaining.decode("utf-8", errors="replace") + break + + ready, _, _ = select.select([process.stdout], [], [], 1.0) + if not ready: + continue + + chunk = os.read(process.stdout.fileno(), 8192) + if not chunk: + break + buffer += chunk.decode("utf-8", errors="replace") + + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if not line: + continue + + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + + # Early detection via stream events + if event.get("type") == "stream_event": + se = event.get("event", {}) + se_type = se.get("type", "") + + if se_type == "content_block_start": + cb = se.get("content_block", {}) + if cb.get("type") == "tool_use": + tool_name = cb.get("name", "") + if tool_name in ("Skill", "Read"): + pending_tool_name = tool_name + accumulated_json = "" + else: + return False + + elif se_type == "content_block_delta" and pending_tool_name: + delta = se.get("delta", {}) + if delta.get("type") == "input_json_delta": + accumulated_json += delta.get("partial_json", "") + if clean_name in accumulated_json: + return True + + elif se_type in ("content_block_stop", "message_stop"): + if pending_tool_name: + return clean_name in accumulated_json + if se_type == "message_stop": + return False + + # Fallback: full assistant message + elif event.get("type") == "assistant": + message = event.get("message", {}) + for content_item in message.get("content", []): + if content_item.get("type") != "tool_use": + continue + tool_name = content_item.get("name", "") + tool_input = content_item.get("input", {}) + if tool_name == "Skill" and clean_name in tool_input.get("skill", ""): + triggered = True + elif tool_name == "Read" and clean_name in tool_input.get("file_path", ""): + triggered = True + return triggered + + elif event.get("type") == "result": + return triggered + finally: + # Clean up process on any exit path (return, exception, timeout) + if process.poll() is None: + process.kill() + process.wait() + + return triggered + finally: + if command_file.exists(): + command_file.unlink() + + +def run_eval( + eval_set: list[dict], + skill_name: str, + description: str, + num_workers: int, + timeout: int, + project_root: Path, + runs_per_query: int = 1, + trigger_threshold: float = 0.5, + model: str | None = None, +) -> dict: + """Run the full eval set and return results.""" + results = [] + + with ProcessPoolExecutor(max_workers=num_workers) as executor: + future_to_info = {} + for item in eval_set: + for run_idx in range(runs_per_query): + future = executor.submit( + run_single_query, + item["query"], + skill_name, + description, + timeout, + str(project_root), + model, + ) + future_to_info[future] = (item, run_idx) + + query_triggers: dict[str, list[bool]] = {} + query_items: dict[str, dict] = {} + for future in as_completed(future_to_info): + item, _ = future_to_info[future] + query = item["query"] + query_items[query] = item + if query not in query_triggers: + query_triggers[query] = [] + try: + query_triggers[query].append(future.result()) + except Exception as e: + print(f"Warning: query failed: {e}", file=sys.stderr) + query_triggers[query].append(False) + + for query, triggers in query_triggers.items(): + item = query_items[query] + trigger_rate = sum(triggers) / len(triggers) + should_trigger = item["should_trigger"] + if should_trigger: + did_pass = trigger_rate >= trigger_threshold + else: + did_pass = trigger_rate < trigger_threshold + results.append({ + "query": query, + "should_trigger": should_trigger, + "trigger_rate": trigger_rate, + "triggers": sum(triggers), + "runs": len(triggers), + "pass": did_pass, + }) + + passed = sum(1 for r in results if r["pass"]) + total = len(results) + + return { + "skill_name": skill_name, + "description": description, + "results": results, + "summary": { + "total": total, + "passed": passed, + "failed": total - passed, + }, + } + + +def main(): + parser = argparse.ArgumentParser(description="Run trigger evaluation for a skill description") + parser.add_argument("--eval-set", required=True, help="Path to eval set JSON file") + parser.add_argument("--skill-path", required=True, help="Path to skill directory") + parser.add_argument("--description", default=None, help="Override description to test") + parser.add_argument("--num-workers", type=int, default=10, help="Number of parallel workers") + parser.add_argument("--timeout", type=int, default=30, help="Timeout per query in seconds") + parser.add_argument("--runs-per-query", type=int, default=3, help="Number of runs per query") + parser.add_argument("--trigger-threshold", type=float, default=0.5, help="Trigger rate threshold") + parser.add_argument("--model", default=None, help="Model to use for claude -p (default: user's configured model)") + parser.add_argument("--verbose", action="store_true", help="Print progress to stderr") + args = parser.parse_args() + + eval_set = json.loads(Path(args.eval_set).read_text()) + skill_path = Path(args.skill_path) + + if not (skill_path / "SKILL.md").exists(): + print(f"Error: No SKILL.md found at {skill_path}", file=sys.stderr) + sys.exit(1) + + name, original_description, content = parse_skill_md(skill_path) + description = args.description or original_description + project_root = find_project_root() + + if args.verbose: + print(f"Evaluating: {description}", file=sys.stderr) + + output = run_eval( + eval_set=eval_set, + skill_name=name, + description=description, + num_workers=args.num_workers, + timeout=args.timeout, + project_root=project_root, + runs_per_query=args.runs_per_query, + trigger_threshold=args.trigger_threshold, + model=args.model, + ) + + if args.verbose: + summary = output["summary"] + print(f"Results: {summary['passed']}/{summary['total']} passed", file=sys.stderr) + for r in output["results"]: + status = "PASS" if r["pass"] else "FAIL" + rate_str = f"{r['triggers']}/{r['runs']}" + print(f" [{status}] rate={rate_str} expected={r['should_trigger']}: {r['query'][:70]}", file=sys.stderr) + + print(json.dumps(output, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/skill-creator/scripts/run_loop.py b/.agents/skills/skill-creator/scripts/run_loop.py new file mode 100755 index 0000000..30a263d --- /dev/null +++ b/.agents/skills/skill-creator/scripts/run_loop.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""Run the eval + improve loop until all pass or max iterations reached. + +Combines run_eval.py and improve_description.py in a loop, tracking history +and returning the best description found. Supports train/test split to prevent +overfitting. +""" + +import argparse +import json +import random +import sys +import tempfile +import time +import webbrowser +from pathlib import Path + +from scripts.generate_report import generate_html +from scripts.improve_description import improve_description +from scripts.run_eval import find_project_root, run_eval +from scripts.utils import parse_skill_md + + +def split_eval_set(eval_set: list[dict], holdout: float, seed: int = 42) -> tuple[list[dict], list[dict]]: + """Split eval set into train and test sets, stratified by should_trigger.""" + random.seed(seed) + + # Separate by should_trigger + trigger = [e for e in eval_set if e["should_trigger"]] + no_trigger = [e for e in eval_set if not e["should_trigger"]] + + # Shuffle each group + random.shuffle(trigger) + random.shuffle(no_trigger) + + # Calculate split points + n_trigger_test = max(1, int(len(trigger) * holdout)) + n_no_trigger_test = max(1, int(len(no_trigger) * holdout)) + + # Split + test_set = trigger[:n_trigger_test] + no_trigger[:n_no_trigger_test] + train_set = trigger[n_trigger_test:] + no_trigger[n_no_trigger_test:] + + return train_set, test_set + + +def run_loop( + eval_set: list[dict], + skill_path: Path, + description_override: str | None, + num_workers: int, + timeout: int, + max_iterations: int, + runs_per_query: int, + trigger_threshold: float, + holdout: float, + model: str, + verbose: bool, + live_report_path: Path | None = None, + log_dir: Path | None = None, +) -> dict: + """Run the eval + improvement loop.""" + project_root = find_project_root() + name, original_description, content = parse_skill_md(skill_path) + current_description = description_override or original_description + + # Split into train/test if holdout > 0 + if holdout > 0: + train_set, test_set = split_eval_set(eval_set, holdout) + if verbose: + print(f"Split: {len(train_set)} train, {len(test_set)} test (holdout={holdout})", file=sys.stderr) + else: + train_set = eval_set + test_set = [] + + history = [] + exit_reason = "unknown" + + for iteration in range(1, max_iterations + 1): + if verbose: + print(f"\n{'='*60}", file=sys.stderr) + print(f"Iteration {iteration}/{max_iterations}", file=sys.stderr) + print(f"Description: {current_description}", file=sys.stderr) + print(f"{'='*60}", file=sys.stderr) + + # Evaluate train + test together in one batch for parallelism + all_queries = train_set + test_set + t0 = time.time() + all_results = run_eval( + eval_set=all_queries, + skill_name=name, + description=current_description, + num_workers=num_workers, + timeout=timeout, + project_root=project_root, + runs_per_query=runs_per_query, + trigger_threshold=trigger_threshold, + model=model, + ) + eval_elapsed = time.time() - t0 + + # Split results back into train/test by matching queries + train_queries_set = {q["query"] for q in train_set} + train_result_list = [r for r in all_results["results"] if r["query"] in train_queries_set] + test_result_list = [r for r in all_results["results"] if r["query"] not in train_queries_set] + + train_passed = sum(1 for r in train_result_list if r["pass"]) + train_total = len(train_result_list) + train_summary = {"passed": train_passed, "failed": train_total - train_passed, "total": train_total} + train_results = {"results": train_result_list, "summary": train_summary} + + if test_set: + test_passed = sum(1 for r in test_result_list if r["pass"]) + test_total = len(test_result_list) + test_summary = {"passed": test_passed, "failed": test_total - test_passed, "total": test_total} + test_results = {"results": test_result_list, "summary": test_summary} + else: + test_results = None + test_summary = None + + history.append({ + "iteration": iteration, + "description": current_description, + "train_passed": train_summary["passed"], + "train_failed": train_summary["failed"], + "train_total": train_summary["total"], + "train_results": train_results["results"], + "test_passed": test_summary["passed"] if test_summary else None, + "test_failed": test_summary["failed"] if test_summary else None, + "test_total": test_summary["total"] if test_summary else None, + "test_results": test_results["results"] if test_results else None, + # For backward compat with report generator + "passed": train_summary["passed"], + "failed": train_summary["failed"], + "total": train_summary["total"], + "results": train_results["results"], + }) + + # Write live report if path provided + if live_report_path: + partial_output = { + "original_description": original_description, + "best_description": current_description, + "best_score": "in progress", + "iterations_run": len(history), + "holdout": holdout, + "train_size": len(train_set), + "test_size": len(test_set), + "history": history, + } + live_report_path.write_text(generate_html(partial_output, auto_refresh=True, skill_name=name)) + + if verbose: + def print_eval_stats(label, results, elapsed): + pos = [r for r in results if r["should_trigger"]] + neg = [r for r in results if not r["should_trigger"]] + tp = sum(r["triggers"] for r in pos) + pos_runs = sum(r["runs"] for r in pos) + fn = pos_runs - tp + fp = sum(r["triggers"] for r in neg) + neg_runs = sum(r["runs"] for r in neg) + tn = neg_runs - fp + total = tp + tn + fp + fn + precision = tp / (tp + fp) if (tp + fp) > 0 else 1.0 + recall = tp / (tp + fn) if (tp + fn) > 0 else 1.0 + accuracy = (tp + tn) / total if total > 0 else 0.0 + print(f"{label}: {tp+tn}/{total} correct, precision={precision:.0%} recall={recall:.0%} accuracy={accuracy:.0%} ({elapsed:.1f}s)", file=sys.stderr) + for r in results: + status = "PASS" if r["pass"] else "FAIL" + rate_str = f"{r['triggers']}/{r['runs']}" + print(f" [{status}] rate={rate_str} expected={r['should_trigger']}: {r['query'][:60]}", file=sys.stderr) + + print_eval_stats("Train", train_results["results"], eval_elapsed) + if test_summary: + print_eval_stats("Test ", test_results["results"], 0) + + if train_summary["failed"] == 0: + exit_reason = f"all_passed (iteration {iteration})" + if verbose: + print(f"\nAll train queries passed on iteration {iteration}!", file=sys.stderr) + break + + if iteration == max_iterations: + exit_reason = f"max_iterations ({max_iterations})" + if verbose: + print(f"\nMax iterations reached ({max_iterations}).", file=sys.stderr) + break + + # Improve the description based on train results + if verbose: + print(f"\nImproving description...", file=sys.stderr) + + t0 = time.time() + # Strip test scores from history so improvement model can't see them + blinded_history = [ + {k: v for k, v in h.items() if not k.startswith("test_")} + for h in history + ] + new_description = improve_description( + skill_name=name, + skill_content=content, + current_description=current_description, + eval_results=train_results, + history=blinded_history, + model=model, + log_dir=log_dir, + iteration=iteration, + ) + improve_elapsed = time.time() - t0 + + if verbose: + print(f"Proposed ({improve_elapsed:.1f}s): {new_description}", file=sys.stderr) + + current_description = new_description + + # Find the best iteration by TEST score (or train if no test set) + if test_set: + best = max(history, key=lambda h: h["test_passed"] or 0) + best_score = f"{best['test_passed']}/{best['test_total']}" + else: + best = max(history, key=lambda h: h["train_passed"]) + best_score = f"{best['train_passed']}/{best['train_total']}" + + if verbose: + print(f"\nExit reason: {exit_reason}", file=sys.stderr) + print(f"Best score: {best_score} (iteration {best['iteration']})", file=sys.stderr) + + return { + "exit_reason": exit_reason, + "original_description": original_description, + "best_description": best["description"], + "best_score": best_score, + "best_train_score": f"{best['train_passed']}/{best['train_total']}", + "best_test_score": f"{best['test_passed']}/{best['test_total']}" if test_set else None, + "final_description": current_description, + "iterations_run": len(history), + "holdout": holdout, + "train_size": len(train_set), + "test_size": len(test_set), + "history": history, + } + + +def main(): + parser = argparse.ArgumentParser(description="Run eval + improve loop") + parser.add_argument("--eval-set", required=True, help="Path to eval set JSON file") + parser.add_argument("--skill-path", required=True, help="Path to skill directory") + parser.add_argument("--description", default=None, help="Override starting description") + parser.add_argument("--num-workers", type=int, default=10, help="Number of parallel workers") + parser.add_argument("--timeout", type=int, default=30, help="Timeout per query in seconds") + parser.add_argument("--max-iterations", type=int, default=5, help="Max improvement iterations") + parser.add_argument("--runs-per-query", type=int, default=3, help="Number of runs per query") + parser.add_argument("--trigger-threshold", type=float, default=0.5, help="Trigger rate threshold") + parser.add_argument("--holdout", type=float, default=0.4, help="Fraction of eval set to hold out for testing (0 to disable)") + parser.add_argument("--model", required=True, help="Model for improvement") + parser.add_argument("--verbose", action="store_true", help="Print progress to stderr") + parser.add_argument("--report", default="auto", help="Generate HTML report at this path (default: 'auto' for temp file, 'none' to disable)") + parser.add_argument("--results-dir", default=None, help="Save all outputs (results.json, report.html, log.txt) to a timestamped subdirectory here") + args = parser.parse_args() + + eval_set = json.loads(Path(args.eval_set).read_text()) + skill_path = Path(args.skill_path) + + if not (skill_path / "SKILL.md").exists(): + print(f"Error: No SKILL.md found at {skill_path}", file=sys.stderr) + sys.exit(1) + + name, _, _ = parse_skill_md(skill_path) + + # Set up live report path + if args.report != "none": + if args.report == "auto": + timestamp = time.strftime("%Y%m%d_%H%M%S") + live_report_path = Path(tempfile.gettempdir()) / f"skill_description_report_{skill_path.name}_{timestamp}.html" + else: + live_report_path = Path(args.report) + # Open the report immediately so the user can watch + live_report_path.write_text("

Starting optimization loop...

") + webbrowser.open(str(live_report_path)) + else: + live_report_path = None + + # Determine output directory (create before run_loop so logs can be written) + if args.results_dir: + timestamp = time.strftime("%Y-%m-%d_%H%M%S") + results_dir = Path(args.results_dir) / timestamp + results_dir.mkdir(parents=True, exist_ok=True) + else: + results_dir = None + + log_dir = results_dir / "logs" if results_dir else None + + output = run_loop( + eval_set=eval_set, + skill_path=skill_path, + description_override=args.description, + num_workers=args.num_workers, + timeout=args.timeout, + max_iterations=args.max_iterations, + runs_per_query=args.runs_per_query, + trigger_threshold=args.trigger_threshold, + holdout=args.holdout, + model=args.model, + verbose=args.verbose, + live_report_path=live_report_path, + log_dir=log_dir, + ) + + # Save JSON output + json_output = json.dumps(output, indent=2) + print(json_output) + if results_dir: + (results_dir / "results.json").write_text(json_output) + + # Write final HTML report (without auto-refresh) + if live_report_path: + live_report_path.write_text(generate_html(output, auto_refresh=False, skill_name=name)) + print(f"\nReport: {live_report_path}", file=sys.stderr) + + if results_dir and live_report_path: + (results_dir / "report.html").write_text(generate_html(output, auto_refresh=False, skill_name=name)) + + if results_dir: + print(f"Results saved to: {results_dir}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/skill-creator/scripts/utils.py b/.agents/skills/skill-creator/scripts/utils.py new file mode 100644 index 0000000..51b6a07 --- /dev/null +++ b/.agents/skills/skill-creator/scripts/utils.py @@ -0,0 +1,47 @@ +"""Shared utilities for skill-creator scripts.""" + +from pathlib import Path + + + +def parse_skill_md(skill_path: Path) -> tuple[str, str, str]: + """Parse a SKILL.md file, returning (name, description, full_content).""" + content = (skill_path / "SKILL.md").read_text() + lines = content.split("\n") + + if lines[0].strip() != "---": + raise ValueError("SKILL.md missing frontmatter (no opening ---)") + + end_idx = None + for i, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + end_idx = i + break + + if end_idx is None: + raise ValueError("SKILL.md missing frontmatter (no closing ---)") + + name = "" + description = "" + frontmatter_lines = lines[1:end_idx] + i = 0 + while i < len(frontmatter_lines): + line = frontmatter_lines[i] + if line.startswith("name:"): + name = line[len("name:"):].strip().strip('"').strip("'") + elif line.startswith("description:"): + value = line[len("description:"):].strip() + # Handle YAML multiline indicators (>, |, >-, |-) + if value in (">", "|", ">-", "|-"): + continuation_lines: list[str] = [] + i += 1 + while i < len(frontmatter_lines) and (frontmatter_lines[i].startswith(" ") or frontmatter_lines[i].startswith("\t")): + continuation_lines.append(frontmatter_lines[i].strip()) + i += 1 + description = " ".join(continuation_lines) + continue + else: + description = value.strip('"').strip("'") + i += 1 + + return name, description, content diff --git a/.agents/skills/slack-gif-creator/LICENSE.txt b/.agents/skills/slack-gif-creator/LICENSE.txt new file mode 100644 index 0000000..4f881c5 --- /dev/null +++ b/.agents/skills/slack-gif-creator/LICENSE.txt @@ -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 2026 Anthropic, PBC. + + 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. \ No newline at end of file diff --git a/.agents/skills/slack-gif-creator/SKILL.md b/.agents/skills/slack-gif-creator/SKILL.md new file mode 100644 index 0000000..16660d8 --- /dev/null +++ b/.agents/skills/slack-gif-creator/SKILL.md @@ -0,0 +1,254 @@ +--- +name: slack-gif-creator +description: Knowledge and utilities for creating animated GIFs optimized for Slack. Provides constraints, validation tools, and animation concepts. Use when users request animated GIFs for Slack like "make me a GIF of X doing Y for Slack." +license: Complete terms in LICENSE.txt +--- + +# Slack GIF Creator + +A toolkit providing utilities and knowledge for creating animated GIFs optimized for Slack. + +## Slack Requirements + +**Dimensions:** +- Emoji GIFs: 128x128 (recommended) +- Message GIFs: 480x480 + +**Parameters:** +- FPS: 10-30 (lower is smaller file size) +- Colors: 48-128 (fewer = smaller file size) +- Duration: Keep under 3 seconds for emoji GIFs + +## Core Workflow + +```python +from core.gif_builder import GIFBuilder +from PIL import Image, ImageDraw + +# 1. Create builder +builder = GIFBuilder(width=128, height=128, fps=10) + +# 2. Generate frames +for i in range(12): + frame = Image.new('RGB', (128, 128), (240, 248, 255)) + draw = ImageDraw.Draw(frame) + + # Draw your animation using PIL primitives + # (circles, polygons, lines, etc.) + + builder.add_frame(frame) + +# 3. Save with optimization +builder.save('output.gif', num_colors=48, optimize_for_emoji=True) +``` + +## Drawing Graphics + +### Working with User-Uploaded Images +If a user uploads an image, consider whether they want to: +- **Use it directly** (e.g., "animate this", "split this into frames") +- **Use it as inspiration** (e.g., "make something like this") + +Load and work with images using PIL: +```python +from PIL import Image + +uploaded = Image.open('file.png') +# Use directly, or just as reference for colors/style +``` + +### Drawing from Scratch +When drawing graphics from scratch, use PIL ImageDraw primitives: + +```python +from PIL import ImageDraw + +draw = ImageDraw.Draw(frame) + +# Circles/ovals +draw.ellipse([x1, y1, x2, y2], fill=(r, g, b), outline=(r, g, b), width=3) + +# Stars, triangles, any polygon +points = [(x1, y1), (x2, y2), (x3, y3), ...] +draw.polygon(points, fill=(r, g, b), outline=(r, g, b), width=3) + +# Lines +draw.line([(x1, y1), (x2, y2)], fill=(r, g, b), width=5) + +# Rectangles +draw.rectangle([x1, y1, x2, y2], fill=(r, g, b), outline=(r, g, b), width=3) +``` + +**Don't use:** Emoji fonts (unreliable across platforms) or assume pre-packaged graphics exist in this skill. + +### Making Graphics Look Good + +Graphics should look polished and creative, not basic. Here's how: + +**Use thicker lines** - Always set `width=2` or higher for outlines and lines. Thin lines (width=1) look choppy and amateurish. + +**Add visual depth**: +- Use gradients for backgrounds (`create_gradient_background`) +- Layer multiple shapes for complexity (e.g., a star with a smaller star inside) + +**Make shapes more interesting**: +- Don't just draw a plain circle - add highlights, rings, or patterns +- Stars can have glows (draw larger, semi-transparent versions behind) +- Combine multiple shapes (stars + sparkles, circles + rings) + +**Pay attention to colors**: +- Use vibrant, complementary colors +- Add contrast (dark outlines on light shapes, light outlines on dark shapes) +- Consider the overall composition + +**For complex shapes** (hearts, snowflakes, etc.): +- Use combinations of polygons and ellipses +- Calculate points carefully for symmetry +- Add details (a heart can have a highlight curve, snowflakes have intricate branches) + +Be creative and detailed! A good Slack GIF should look polished, not like placeholder graphics. + +## Available Utilities + +### GIFBuilder (`core.gif_builder`) +Assembles frames and optimizes for Slack: +```python +builder = GIFBuilder(width=128, height=128, fps=10) +builder.add_frame(frame) # Add PIL Image +builder.add_frames(frames) # Add list of frames +builder.save('out.gif', num_colors=48, optimize_for_emoji=True, remove_duplicates=True) +``` + +### Validators (`core.validators`) +Check if GIF meets Slack requirements: +```python +from core.validators import validate_gif, is_slack_ready + +# Detailed validation +passes, info = validate_gif('my.gif', is_emoji=True, verbose=True) + +# Quick check +if is_slack_ready('my.gif'): + print("Ready!") +``` + +### Easing Functions (`core.easing`) +Smooth motion instead of linear: +```python +from core.easing import interpolate + +# Progress from 0.0 to 1.0 +t = i / (num_frames - 1) + +# Apply easing +y = interpolate(start=0, end=400, t=t, easing='ease_out') + +# Available: linear, ease_in, ease_out, ease_in_out, +# bounce_out, elastic_out, back_out +``` + +### Frame Helpers (`core.frame_composer`) +Convenience functions for common needs: +```python +from core.frame_composer import ( + create_blank_frame, # Solid color background + create_gradient_background, # Vertical gradient + draw_circle, # Helper for circles + draw_text, # Simple text rendering + draw_star # 5-pointed star +) +``` + +## Animation Concepts + +### Shake/Vibrate +Offset object position with oscillation: +- Use `math.sin()` or `math.cos()` with frame index +- Add small random variations for natural feel +- Apply to x and/or y position + +### Pulse/Heartbeat +Scale object size rhythmically: +- Use `math.sin(t * frequency * 2 * math.pi)` for smooth pulse +- For heartbeat: two quick pulses then pause (adjust sine wave) +- Scale between 0.8 and 1.2 of base size + +### Bounce +Object falls and bounces: +- Use `interpolate()` with `easing='bounce_out'` for landing +- Use `easing='ease_in'` for falling (accelerating) +- Apply gravity by increasing y velocity each frame + +### Spin/Rotate +Rotate object around center: +- PIL: `image.rotate(angle, resample=Image.BICUBIC)` +- For wobble: use sine wave for angle instead of linear + +### Fade In/Out +Gradually appear or disappear: +- Create RGBA image, adjust alpha channel +- Or use `Image.blend(image1, image2, alpha)` +- Fade in: alpha from 0 to 1 +- Fade out: alpha from 1 to 0 + +### Slide +Move object from off-screen to position: +- Start position: outside frame bounds +- End position: target location +- Use `interpolate()` with `easing='ease_out'` for smooth stop +- For overshoot: use `easing='back_out'` + +### Zoom +Scale and position for zoom effect: +- Zoom in: scale from 0.1 to 2.0, crop center +- Zoom out: scale from 2.0 to 1.0 +- Can add motion blur for drama (PIL filter) + +### Explode/Particle Burst +Create particles radiating outward: +- Generate particles with random angles and velocities +- Update each particle: `x += vx`, `y += vy` +- Add gravity: `vy += gravity_constant` +- Fade out particles over time (reduce alpha) + +## Optimization Strategies + +Only when asked to make the file size smaller, implement a few of the following methods: + +1. **Fewer frames** - Lower FPS (10 instead of 20) or shorter duration +2. **Fewer colors** - `num_colors=48` instead of 128 +3. **Smaller dimensions** - 128x128 instead of 480x480 +4. **Remove duplicates** - `remove_duplicates=True` in save() +5. **Emoji mode** - `optimize_for_emoji=True` auto-optimizes + +```python +# Maximum optimization for emoji +builder.save( + 'emoji.gif', + num_colors=48, + optimize_for_emoji=True, + remove_duplicates=True +) +``` + +## Philosophy + +This skill provides: +- **Knowledge**: Slack's requirements and animation concepts +- **Utilities**: GIFBuilder, validators, easing functions +- **Flexibility**: Create the animation logic using PIL primitives + +It does NOT provide: +- Rigid animation templates or pre-made functions +- Emoji font rendering (unreliable across platforms) +- A library of pre-packaged graphics built into the skill + +**Note on user uploads**: This skill doesn't include pre-built graphics, but if a user uploads an image, use PIL to load and work with it - interpret based on their request whether they want it used directly or just as inspiration. + +Be creative! Combine concepts (bouncing + rotating, pulsing + sliding, etc.) and use PIL's full capabilities. + +## Dependencies + +```bash +pip install pillow imageio numpy +``` diff --git a/.agents/skills/slack-gif-creator/core/easing.py b/.agents/skills/slack-gif-creator/core/easing.py new file mode 100755 index 0000000..772fa83 --- /dev/null +++ b/.agents/skills/slack-gif-creator/core/easing.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +""" +Easing Functions - Timing functions for smooth animations. + +Provides various easing functions for natural motion and timing. +All functions take a value t (0.0 to 1.0) and return eased value (0.0 to 1.0). +""" + +import math + + +def linear(t: float) -> float: + """Linear interpolation (no easing).""" + return t + + +def ease_in_quad(t: float) -> float: + """Quadratic ease-in (slow start, accelerating).""" + return t * t + + +def ease_out_quad(t: float) -> float: + """Quadratic ease-out (fast start, decelerating).""" + return t * (2 - t) + + +def ease_in_out_quad(t: float) -> float: + """Quadratic ease-in-out (slow start and end).""" + if t < 0.5: + return 2 * t * t + return -1 + (4 - 2 * t) * t + + +def ease_in_cubic(t: float) -> float: + """Cubic ease-in (slow start).""" + return t * t * t + + +def ease_out_cubic(t: float) -> float: + """Cubic ease-out (fast start).""" + return (t - 1) * (t - 1) * (t - 1) + 1 + + +def ease_in_out_cubic(t: float) -> float: + """Cubic ease-in-out.""" + if t < 0.5: + return 4 * t * t * t + return (t - 1) * (2 * t - 2) * (2 * t - 2) + 1 + + +def ease_in_bounce(t: float) -> float: + """Bounce ease-in (bouncy start).""" + return 1 - ease_out_bounce(1 - t) + + +def ease_out_bounce(t: float) -> float: + """Bounce ease-out (bouncy end).""" + if t < 1 / 2.75: + return 7.5625 * t * t + elif t < 2 / 2.75: + t -= 1.5 / 2.75 + return 7.5625 * t * t + 0.75 + elif t < 2.5 / 2.75: + t -= 2.25 / 2.75 + return 7.5625 * t * t + 0.9375 + else: + t -= 2.625 / 2.75 + return 7.5625 * t * t + 0.984375 + + +def ease_in_out_bounce(t: float) -> float: + """Bounce ease-in-out.""" + if t < 0.5: + return ease_in_bounce(t * 2) * 0.5 + return ease_out_bounce(t * 2 - 1) * 0.5 + 0.5 + + +def ease_in_elastic(t: float) -> float: + """Elastic ease-in (spring effect).""" + if t == 0 or t == 1: + return t + return -math.pow(2, 10 * (t - 1)) * math.sin((t - 1.1) * 5 * math.pi) + + +def ease_out_elastic(t: float) -> float: + """Elastic ease-out (spring effect).""" + if t == 0 or t == 1: + return t + return math.pow(2, -10 * t) * math.sin((t - 0.1) * 5 * math.pi) + 1 + + +def ease_in_out_elastic(t: float) -> float: + """Elastic ease-in-out.""" + if t == 0 or t == 1: + return t + t = t * 2 - 1 + if t < 0: + return -0.5 * math.pow(2, 10 * t) * math.sin((t - 0.1) * 5 * math.pi) + return math.pow(2, -10 * t) * math.sin((t - 0.1) * 5 * math.pi) * 0.5 + 1 + + +# Convenience mapping +EASING_FUNCTIONS = { + "linear": linear, + "ease_in": ease_in_quad, + "ease_out": ease_out_quad, + "ease_in_out": ease_in_out_quad, + "bounce_in": ease_in_bounce, + "bounce_out": ease_out_bounce, + "bounce": ease_in_out_bounce, + "elastic_in": ease_in_elastic, + "elastic_out": ease_out_elastic, + "elastic": ease_in_out_elastic, +} + + +def get_easing(name: str = "linear"): + """Get easing function by name.""" + return EASING_FUNCTIONS.get(name, linear) + + +def interpolate(start: float, end: float, t: float, easing: str = "linear") -> float: + """ + Interpolate between two values with easing. + + Args: + start: Start value + end: End value + t: Progress from 0.0 to 1.0 + easing: Name of easing function + + Returns: + Interpolated value + """ + ease_func = get_easing(easing) + eased_t = ease_func(t) + return start + (end - start) * eased_t + + +def ease_back_in(t: float) -> float: + """Back ease-in (slight overshoot backward before forward motion).""" + c1 = 1.70158 + c3 = c1 + 1 + return c3 * t * t * t - c1 * t * t + + +def ease_back_out(t: float) -> float: + """Back ease-out (overshoot forward then settle back).""" + c1 = 1.70158 + c3 = c1 + 1 + return 1 + c3 * pow(t - 1, 3) + c1 * pow(t - 1, 2) + + +def ease_back_in_out(t: float) -> float: + """Back ease-in-out (overshoot at both ends).""" + c1 = 1.70158 + c2 = c1 * 1.525 + if t < 0.5: + return (pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2 + return (pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2 + + +def apply_squash_stretch( + base_scale: tuple[float, float], intensity: float, direction: str = "vertical" +) -> tuple[float, float]: + """ + Calculate squash and stretch scales for more dynamic animation. + + Args: + base_scale: (width_scale, height_scale) base scales + intensity: Squash/stretch intensity (0.0-1.0) + direction: 'vertical', 'horizontal', or 'both' + + Returns: + (width_scale, height_scale) with squash/stretch applied + """ + width_scale, height_scale = base_scale + + if direction == "vertical": + # Compress vertically, expand horizontally (preserve volume) + height_scale *= 1 - intensity * 0.5 + width_scale *= 1 + intensity * 0.5 + elif direction == "horizontal": + # Compress horizontally, expand vertically + width_scale *= 1 - intensity * 0.5 + height_scale *= 1 + intensity * 0.5 + elif direction == "both": + # General squash (both dimensions) + width_scale *= 1 - intensity * 0.3 + height_scale *= 1 - intensity * 0.3 + + return (width_scale, height_scale) + + +def calculate_arc_motion( + start: tuple[float, float], end: tuple[float, float], height: float, t: float +) -> tuple[float, float]: + """ + Calculate position along a parabolic arc (natural motion path). + + Args: + start: (x, y) starting position + end: (x, y) ending position + height: Arc height at midpoint (positive = upward) + t: Progress (0.0-1.0) + + Returns: + (x, y) position along arc + """ + x1, y1 = start + x2, y2 = end + + # Linear interpolation for x + x = x1 + (x2 - x1) * t + + # Parabolic interpolation for y + # y = start + progress * (end - start) + arc_offset + # Arc offset peaks at t=0.5 + arc_offset = 4 * height * t * (1 - t) + y = y1 + (y2 - y1) * t - arc_offset + + return (x, y) + + +# Add new easing functions to the convenience mapping +EASING_FUNCTIONS.update( + { + "back_in": ease_back_in, + "back_out": ease_back_out, + "back_in_out": ease_back_in_out, + "anticipate": ease_back_in, # Alias + "overshoot": ease_back_out, # Alias + } +) diff --git a/.agents/skills/slack-gif-creator/core/frame_composer.py b/.agents/skills/slack-gif-creator/core/frame_composer.py new file mode 100755 index 0000000..1afe434 --- /dev/null +++ b/.agents/skills/slack-gif-creator/core/frame_composer.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +""" +Frame Composer - Utilities for composing visual elements into frames. + +Provides functions for drawing shapes, text, emojis, and compositing elements +together to create animation frames. +""" + +from typing import Optional + +import numpy as np +from PIL import Image, ImageDraw, ImageFont + + +def create_blank_frame( + width: int, height: int, color: tuple[int, int, int] = (255, 255, 255) +) -> Image.Image: + """ + Create a blank frame with solid color background. + + Args: + width: Frame width + height: Frame height + color: RGB color tuple (default: white) + + Returns: + PIL Image + """ + return Image.new("RGB", (width, height), color) + + +def draw_circle( + frame: Image.Image, + center: tuple[int, int], + radius: int, + fill_color: Optional[tuple[int, int, int]] = None, + outline_color: Optional[tuple[int, int, int]] = None, + outline_width: int = 1, +) -> Image.Image: + """ + Draw a circle on a frame. + + Args: + frame: PIL Image to draw on + center: (x, y) center position + radius: Circle radius + fill_color: RGB fill color (None for no fill) + outline_color: RGB outline color (None for no outline) + outline_width: Outline width in pixels + + Returns: + Modified frame + """ + draw = ImageDraw.Draw(frame) + x, y = center + bbox = [x - radius, y - radius, x + radius, y + radius] + draw.ellipse(bbox, fill=fill_color, outline=outline_color, width=outline_width) + return frame + + +def draw_text( + frame: Image.Image, + text: str, + position: tuple[int, int], + color: tuple[int, int, int] = (0, 0, 0), + centered: bool = False, +) -> Image.Image: + """ + Draw text on a frame. + + Args: + frame: PIL Image to draw on + text: Text to draw + position: (x, y) position (top-left unless centered=True) + color: RGB text color + centered: If True, center text at position + + Returns: + Modified frame + """ + draw = ImageDraw.Draw(frame) + + # Uses Pillow's default font. + # If the font should be changed for the emoji, add additional logic here. + font = ImageFont.load_default() + + if centered: + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + x = position[0] - text_width // 2 + y = position[1] - text_height // 2 + position = (x, y) + + draw.text(position, text, fill=color, font=font) + return frame + + +def create_gradient_background( + width: int, + height: int, + top_color: tuple[int, int, int], + bottom_color: tuple[int, int, int], +) -> Image.Image: + """ + Create a vertical gradient background. + + Args: + width: Frame width + height: Frame height + top_color: RGB color at top + bottom_color: RGB color at bottom + + Returns: + PIL Image with gradient + """ + frame = Image.new("RGB", (width, height)) + draw = ImageDraw.Draw(frame) + + # Calculate color step for each row + r1, g1, b1 = top_color + r2, g2, b2 = bottom_color + + for y in range(height): + # Interpolate color + ratio = y / height + r = int(r1 * (1 - ratio) + r2 * ratio) + g = int(g1 * (1 - ratio) + g2 * ratio) + b = int(b1 * (1 - ratio) + b2 * ratio) + + # Draw horizontal line + draw.line([(0, y), (width, y)], fill=(r, g, b)) + + return frame + + +def draw_star( + frame: Image.Image, + center: tuple[int, int], + size: int, + fill_color: tuple[int, int, int], + outline_color: Optional[tuple[int, int, int]] = None, + outline_width: int = 1, +) -> Image.Image: + """ + Draw a 5-pointed star. + + Args: + frame: PIL Image to draw on + center: (x, y) center position + size: Star size (outer radius) + fill_color: RGB fill color + outline_color: RGB outline color (None for no outline) + outline_width: Outline width + + Returns: + Modified frame + """ + import math + + draw = ImageDraw.Draw(frame) + x, y = center + + # Calculate star points + points = [] + for i in range(10): + angle = (i * 36 - 90) * math.pi / 180 # 36 degrees per point, start at top + radius = size if i % 2 == 0 else size * 0.4 # Alternate between outer and inner + px = x + radius * math.cos(angle) + py = y + radius * math.sin(angle) + points.append((px, py)) + + # Draw star + draw.polygon(points, fill=fill_color, outline=outline_color, width=outline_width) + + return frame diff --git a/.agents/skills/slack-gif-creator/core/gif_builder.py b/.agents/skills/slack-gif-creator/core/gif_builder.py new file mode 100755 index 0000000..5759f14 --- /dev/null +++ b/.agents/skills/slack-gif-creator/core/gif_builder.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +""" +GIF Builder - Core module for assembling frames into GIFs optimized for Slack. + +This module provides the main interface for creating GIFs from programmatically +generated frames, with automatic optimization for Slack's requirements. +""" + +from pathlib import Path +from typing import Optional + +import imageio.v3 as imageio +import numpy as np +from PIL import Image + + +class GIFBuilder: + """Builder for creating optimized GIFs from frames.""" + + def __init__(self, width: int = 480, height: int = 480, fps: int = 15): + """ + Initialize GIF builder. + + Args: + width: Frame width in pixels + height: Frame height in pixels + fps: Frames per second + """ + self.width = width + self.height = height + self.fps = fps + self.frames: list[np.ndarray] = [] + + def add_frame(self, frame: np.ndarray | Image.Image): + """ + Add a frame to the GIF. + + Args: + frame: Frame as numpy array or PIL Image (will be converted to RGB) + """ + if isinstance(frame, Image.Image): + frame = np.array(frame.convert("RGB")) + + # Ensure frame is correct size + if frame.shape[:2] != (self.height, self.width): + pil_frame = Image.fromarray(frame) + pil_frame = pil_frame.resize( + (self.width, self.height), Image.Resampling.LANCZOS + ) + frame = np.array(pil_frame) + + self.frames.append(frame) + + def add_frames(self, frames: list[np.ndarray | Image.Image]): + """Add multiple frames at once.""" + for frame in frames: + self.add_frame(frame) + + def optimize_colors( + self, num_colors: int = 128, use_global_palette: bool = True + ) -> list[np.ndarray]: + """ + Reduce colors in all frames using quantization. + + Args: + num_colors: Target number of colors (8-256) + use_global_palette: Use a single palette for all frames (better compression) + + Returns: + List of color-optimized frames + """ + optimized = [] + + if use_global_palette and len(self.frames) > 1: + # Create a global palette from all frames + # Sample frames to build palette + sample_size = min(5, len(self.frames)) + sample_indices = [ + int(i * len(self.frames) / sample_size) for i in range(sample_size) + ] + sample_frames = [self.frames[i] for i in sample_indices] + + # Combine sample frames into a single image for palette generation + # Flatten each frame to get all pixels, then stack them + all_pixels = np.vstack( + [f.reshape(-1, 3) for f in sample_frames] + ) # (total_pixels, 3) + + # Create a properly-shaped RGB image from the pixel data + # We'll make a roughly square image from all the pixels + total_pixels = len(all_pixels) + width = min(512, int(np.sqrt(total_pixels))) # Reasonable width, max 512 + height = (total_pixels + width - 1) // width # Ceiling division + + # Pad if necessary to fill the rectangle + pixels_needed = width * height + if pixels_needed > total_pixels: + padding = np.zeros((pixels_needed - total_pixels, 3), dtype=np.uint8) + all_pixels = np.vstack([all_pixels, padding]) + + # Reshape to proper RGB image format (H, W, 3) + img_array = ( + all_pixels[:pixels_needed].reshape(height, width, 3).astype(np.uint8) + ) + combined_img = Image.fromarray(img_array, mode="RGB") + + # Generate global palette + global_palette = combined_img.quantize(colors=num_colors, method=2) + + # Apply global palette to all frames + for frame in self.frames: + pil_frame = Image.fromarray(frame) + quantized = pil_frame.quantize(palette=global_palette, dither=1) + optimized.append(np.array(quantized.convert("RGB"))) + else: + # Use per-frame quantization + for frame in self.frames: + pil_frame = Image.fromarray(frame) + quantized = pil_frame.quantize(colors=num_colors, method=2, dither=1) + optimized.append(np.array(quantized.convert("RGB"))) + + return optimized + + def deduplicate_frames(self, threshold: float = 0.9995) -> int: + """ + Remove duplicate or near-duplicate consecutive frames. + + Args: + threshold: Similarity threshold (0.0-1.0). Higher = more strict (0.9995 = nearly identical). + Use 0.9995+ to preserve subtle animations, 0.98 for aggressive removal. + + Returns: + Number of frames removed + """ + if len(self.frames) < 2: + return 0 + + deduplicated = [self.frames[0]] + removed_count = 0 + + for i in range(1, len(self.frames)): + # Compare with previous frame + prev_frame = np.array(deduplicated[-1], dtype=np.float32) + curr_frame = np.array(self.frames[i], dtype=np.float32) + + # Calculate similarity (normalized) + diff = np.abs(prev_frame - curr_frame) + similarity = 1.0 - (np.mean(diff) / 255.0) + + # Keep frame if sufficiently different + # High threshold (0.9995+) means only remove nearly identical frames + if similarity < threshold: + deduplicated.append(self.frames[i]) + else: + removed_count += 1 + + self.frames = deduplicated + return removed_count + + def save( + self, + output_path: str | Path, + num_colors: int = 128, + optimize_for_emoji: bool = False, + remove_duplicates: bool = False, + ) -> dict: + """ + Save frames as optimized GIF for Slack. + + Args: + output_path: Where to save the GIF + num_colors: Number of colors to use (fewer = smaller file) + optimize_for_emoji: If True, optimize for emoji size (128x128, fewer colors) + remove_duplicates: If True, remove duplicate consecutive frames (opt-in) + + Returns: + Dictionary with file info (path, size, dimensions, frame_count) + """ + if not self.frames: + raise ValueError("No frames to save. Add frames with add_frame() first.") + + output_path = Path(output_path) + + # Remove duplicate frames to reduce file size + if remove_duplicates: + removed = self.deduplicate_frames(threshold=0.9995) + if removed > 0: + print( + f" Removed {removed} nearly identical frames (preserved subtle animations)" + ) + + # Optimize for emoji if requested + if optimize_for_emoji: + if self.width > 128 or self.height > 128: + print( + f" Resizing from {self.width}x{self.height} to 128x128 for emoji" + ) + self.width = 128 + self.height = 128 + # Resize all frames + resized_frames = [] + for frame in self.frames: + pil_frame = Image.fromarray(frame) + pil_frame = pil_frame.resize((128, 128), Image.Resampling.LANCZOS) + resized_frames.append(np.array(pil_frame)) + self.frames = resized_frames + num_colors = min(num_colors, 48) # More aggressive color limit for emoji + + # More aggressive FPS reduction for emoji + if len(self.frames) > 12: + print( + f" Reducing frames from {len(self.frames)} to ~12 for emoji size" + ) + # Keep every nth frame to get close to 12 frames + keep_every = max(1, len(self.frames) // 12) + self.frames = [ + self.frames[i] for i in range(0, len(self.frames), keep_every) + ] + + # Optimize colors with global palette + optimized_frames = self.optimize_colors(num_colors, use_global_palette=True) + + # Calculate frame duration in milliseconds + frame_duration = 1000 / self.fps + + # Save GIF + imageio.imwrite( + output_path, + optimized_frames, + duration=frame_duration, + loop=0, # Infinite loop + ) + + # Get file info + file_size_kb = output_path.stat().st_size / 1024 + file_size_mb = file_size_kb / 1024 + + info = { + "path": str(output_path), + "size_kb": file_size_kb, + "size_mb": file_size_mb, + "dimensions": f"{self.width}x{self.height}", + "frame_count": len(optimized_frames), + "fps": self.fps, + "duration_seconds": len(optimized_frames) / self.fps, + "colors": num_colors, + } + + # Print info + print(f"\n✓ GIF created successfully!") + print(f" Path: {output_path}") + print(f" Size: {file_size_kb:.1f} KB ({file_size_mb:.2f} MB)") + print(f" Dimensions: {self.width}x{self.height}") + print(f" Frames: {len(optimized_frames)} @ {self.fps} fps") + print(f" Duration: {info['duration_seconds']:.1f}s") + print(f" Colors: {num_colors}") + + # Size info + if optimize_for_emoji: + print(f" Optimized for emoji (128x128, reduced colors)") + if file_size_mb > 1.0: + print(f"\n Note: Large file size ({file_size_kb:.1f} KB)") + print(" Consider: fewer frames, smaller dimensions, or fewer colors") + + return info + + def clear(self): + """Clear all frames (useful for creating multiple GIFs).""" + self.frames = [] diff --git a/.agents/skills/slack-gif-creator/core/validators.py b/.agents/skills/slack-gif-creator/core/validators.py new file mode 100755 index 0000000..a6f5bdf --- /dev/null +++ b/.agents/skills/slack-gif-creator/core/validators.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +""" +Validators - Check if GIFs meet Slack's requirements. + +These validators help ensure your GIFs meet Slack's size and dimension constraints. +""" + +from pathlib import Path + + +def validate_gif( + gif_path: str | Path, is_emoji: bool = True, verbose: bool = True +) -> tuple[bool, dict]: + """ + Validate GIF for Slack (dimensions, size, frame count). + + Args: + gif_path: Path to GIF file + is_emoji: True for emoji (128x128 recommended), False for message GIF + verbose: Print validation details + + Returns: + Tuple of (passes: bool, results: dict with all details) + """ + from PIL import Image + + gif_path = Path(gif_path) + + if not gif_path.exists(): + return False, {"error": f"File not found: {gif_path}"} + + # Get file size + size_bytes = gif_path.stat().st_size + size_kb = size_bytes / 1024 + size_mb = size_kb / 1024 + + # Get dimensions and frame info + try: + with Image.open(gif_path) as img: + width, height = img.size + + # Count frames + frame_count = 0 + try: + while True: + img.seek(frame_count) + frame_count += 1 + except EOFError: + pass + + # Get duration + try: + duration_ms = img.info.get("duration", 100) + total_duration = (duration_ms * frame_count) / 1000 + fps = frame_count / total_duration if total_duration > 0 else 0 + except: + total_duration = None + fps = None + + except Exception as e: + return False, {"error": f"Failed to read GIF: {e}"} + + # Validate dimensions + if is_emoji: + optimal = width == height == 128 + acceptable = width == height and 64 <= width <= 128 + dim_pass = acceptable + else: + aspect_ratio = ( + max(width, height) / min(width, height) + if min(width, height) > 0 + else float("inf") + ) + dim_pass = aspect_ratio <= 2.0 and 320 <= min(width, height) <= 640 + + results = { + "file": str(gif_path), + "passes": dim_pass, + "width": width, + "height": height, + "size_kb": size_kb, + "size_mb": size_mb, + "frame_count": frame_count, + "duration_seconds": total_duration, + "fps": fps, + "is_emoji": is_emoji, + "optimal": optimal if is_emoji else None, + } + + # Print if verbose + if verbose: + print(f"\nValidating {gif_path.name}:") + print( + f" Dimensions: {width}x{height}" + + ( + f" ({'optimal' if optimal else 'acceptable'})" + if is_emoji and acceptable + else "" + ) + ) + print( + f" Size: {size_kb:.1f} KB" + + (f" ({size_mb:.2f} MB)" if size_mb >= 1.0 else "") + ) + print( + f" Frames: {frame_count}" + + (f" @ {fps:.1f} fps ({total_duration:.1f}s)" if fps else "") + ) + + if not dim_pass: + print( + f" Note: {'Emoji should be 128x128' if is_emoji else 'Unusual dimensions for Slack'}" + ) + + if size_mb > 5.0: + print(f" Note: Large file size - consider fewer frames/colors") + + return dim_pass, results + + +def is_slack_ready( + gif_path: str | Path, is_emoji: bool = True, verbose: bool = True +) -> bool: + """ + Quick check if GIF is ready for Slack. + + Args: + gif_path: Path to GIF file + is_emoji: True for emoji GIF, False for message GIF + verbose: Print feedback + + Returns: + True if dimensions are acceptable + """ + passes, _ = validate_gif(gif_path, is_emoji, verbose) + return passes diff --git a/.agents/skills/slack-gif-creator/requirements.txt b/.agents/skills/slack-gif-creator/requirements.txt new file mode 100644 index 0000000..8bc4493 --- /dev/null +++ b/.agents/skills/slack-gif-creator/requirements.txt @@ -0,0 +1,4 @@ +pillow>=10.0.0 +imageio>=2.31.0 +imageio-ffmpeg>=0.4.9 +numpy>=1.24.0 \ No newline at end of file diff --git a/.agents/skills/stitch-code-to-design/SKILL.md b/.agents/skills/stitch-code-to-design/SKILL.md new file mode 100644 index 0000000..70558c8 --- /dev/null +++ b/.agents/skills/stitch-code-to-design/SKILL.md @@ -0,0 +1,77 @@ +--- +name: stitch::code-to-design +description: >- + Convert frontend code (Vite, React, etc.) to a Stitch Design by chaining + static HTML extraction, design system extraction, and file upload. **ALWAYS** use this skill when the user's intent is to move existing web apps or React components into Stitch (e.g., requests to "save", "migrate", or "upload"). You must use this skill even for simple "save" operations, as it is the only way to ensure the design system is extracted and assets are properly linked. +allowed-tools: + - "stitch*:*" + - "Bash" + - "Read" + - "Write" + - "web_fetch" +--- + +# Code to Design + +Transform your existing frontend code into a Stitch Design so you can iterate and improve it using Stitch. + +This skill orchestrates three other skills in sequence: +1. `extract-static-html`: Extract a single self-contained HTML file from your build output. +2. `extract-design-md`: Analyze the source code to create a design system (DESIGN.md). +3. `upload-to-stitch`: Upload that HTML file and the design system to your Stitch project. + +## Workflow + +Follow these steps to convert your existing code. + +### Prerequisites + +- A built web application directory containing `index.html` and assets. +- Target Stitch `projectId` (use `list_projects` if unknown). + +### Steps + +#### 1. Extract Self-Contained HTML + +Delegate to the `extract-static-html` skill to generate a standalone HTML file. +Read [skills/extract-static-html/SKILL.md](../extract-static-html/SKILL.md) for detailed instructions and script usage. + +Expected output: A single file like `/path/to/extracted/standalone.html`. + +#### 2. Verify HTML (Optional — User-Driven) + +After extraction, inform the user of the output file path so they can manually +verify in a browser if desired. **Do not block on verification** — proceed +directly to Step 3. + +If the user reports issues after reviewing, fix them before continuing. + +#### 3. Extract Design System (File) + +Delegate to the `extract-design-md` skill to analyze the project's source files +(components, stylesheets, theme configs) and produce a design system. Read +[skills/extract-design-md/SKILL.md](../extract-design-md/SKILL.md) for the +full analysis workflow. + +Write `.stitch/DESIGN.md` following the `extract-design-md` skill's output +structure. + +#### 4. Upload DESIGN.md and Create Design System in Stitch + +Delegate to the `manage-design-system` skill to upload the `DESIGN.md` and +create the design system in Stitch. Read +[skills/manage-design-system/SKILL.md](../manage-design-system/SKILL.md) for +the full workflow (upload script usage, `create_design_system_from_design_md` +call, and required schemas). Pass +`--generated-by 'stitch::code-to-design'` when uploading. + +#### 5. Upload HTML to Stitch + +Use the same `upload-to-stitch` skill's script to upload the extracted HTML file. +Read [skills/upload-to-stitch/SKILL.md](../upload-to-stitch/SKILL.md) for detailed instructions and script usage. + +You will need: +- The path to the standalone HTML file generated in Step 1. +- Your Stitch API Key (same key used in Step 4). +- The target `projectId`. +- The `--generated-by` argument set to `'stitch::extract-static-html'`. \ No newline at end of file diff --git a/.agents/skills/stitch-extract-design-md/SKILL.md b/.agents/skills/stitch-extract-design-md/SKILL.md new file mode 100644 index 0000000..94228a6 --- /dev/null +++ b/.agents/skills/stitch-extract-design-md/SKILL.md @@ -0,0 +1,365 @@ +--- +name: stitch::extract-design-md +description: >- + Extract a comprehensive design system (DESIGN.md) directly from frontend source + code — React, Vue, Svelte, Angular, plain HTML/CSS, or any web framework. Analyzes + component files, stylesheets, Tailwind configs, theme definitions, and design tokens + to produce a rich, Stitch-compatible design system document. Use this skill whenever + the user wants to reverse-engineer a design system from an existing codebase, audit + the visual language of a project, extract design tokens from source files, or + understand the styling patterns in a frontend repo — even if they just say "what + does this app look like?" or "pull out the design from this code." +allowed-tools: + - "stitch*:*" + - "Bash" + - "Read" + - "Write" + - "web_fetch" +--- + +# Extract Design System from Frontend Code + +Analyze frontend source code to extract a comprehensive design system document +(DESIGN.md) that captures the project's visual language — colors, typography, +spacing, component patterns, and layout principles — directly from the source +files, without needing to build or render the application. + +## Why This Exists + +The `design-md` skill works from rendered HTML. But often you have a codebase +and want to understand its design system before you can even run the app — +maybe dependencies are missing, the build is broken, or you just want a quick +audit. This skill reads the source files themselves: stylesheets, component +files, theme configs, and Tailwind setups. It's faster and works anywhere. + +## When to Use + +- User has a frontend codebase and wants to extract or document its design system +- User wants to migrate a project's visual identity into Stitch +- User asks to "audit the styling" or "understand the design language" of a repo +- User wants to create a DESIGN.md from existing source code +- The app can't be built/rendered but the source is available +- User wants to unify or reconcile inconsistent styles across a codebase + +## Prerequisites + +- Access to the frontend project's source directory +- No build or runtime dependencies needed — this skill reads source files only + +--- + +## Workflow + +### Phase 1: Project Discovery + +Start by understanding what you're working with. This determines which +extraction patterns to use. + +#### 1. Detect the Framework and Stack + +Scan the project root for telltale files: + +| Signal File | Framework / Tool | +|:---|:---| +| `package.json` with `react` | React / Next.js | +| `package.json` with `vue` | Vue / Nuxt | +| `package.json` with `svelte` | Svelte / SvelteKit | +| `package.json` with `@angular/core` | Angular | +| `tailwind.config.js/ts` | Tailwind CSS | +| `postcss.config.js` | PostCSS pipeline | +| `styled-components` or `@emotion` in deps | CSS-in-JS | +| `.css` / `.scss` / `.less` files only | Plain CSS / SASS | +| `theme.js` / `theme.ts` / `tokens.js` | Design token files | + +Read `package.json` first — it reveals the framework, CSS tooling, and any +design-token libraries (e.g., `style-dictionary`, `@chakra-ui/react`, +`@mui/material`, `ant-design`). This context tells you *where* to look for +styling information. + +#### 2. Map the Source Tree + +Identify the key directories and files you'll analyze: + +``` +src/ +├── components/ ← Component-level styles +├── styles/ ← Global stylesheets +├── theme/ ← Theme definitions, tokens +├── assets/ ← Fonts, images +├── app.css ← Root styles +└── index.css ← Entry CSS +``` + +Also check for: +- `tailwind.config.js` / `tailwind.config.ts` — Custom colors, fonts, spacing +- `globals.css` / `global.css` — CSS custom properties (variables) +- Any `theme.*` or `tokens.*` files +- Component library config (e.g., `chakra-theme.ts`, `vuetify.config.ts`) + +#### 3. Read Framework-Specific Guidance + +Consult the appropriate reference for extraction patterns: + +- **React / Next.js / Tailwind** → [references/react-tailwind.md](references/react-tailwind.md) +- **Vue / Nuxt** → [references/vue.md](references/vue.md) +- **Svelte / SvelteKit** → [references/svelte.md](references/svelte.md) +- **Angular** → [references/angular.md](references/angular.md) +- **Plain CSS / SASS / Less** → [references/plain-css.md](references/plain-css.md) + +These references contain framework-specific patterns for locating colors, +typography, spacing, and component styles. Read the one that matches before +proceeding. + +--- + +### Phase 2: Deep Extraction + +Work through each design dimension systematically. For each one, gather raw +data from the source files, then synthesize it into descriptive language. + +The goal isn't to dump every CSS property — it's to understand the *intent* +behind the styling choices and describe them in human, editorial language that +another designer (or Stitch) can use to recreate the same visual feel. + +#### 1. Visual Theme & Atmosphere + +Read the broadest styling first to understand the overall mood: + +- **Root background**: What's the `body` or root element background? Light + cream (#f-range) signals airy/clean; dark (#0-#2 range) signals moody/dramatic. +- **Whitespace philosophy**: Are spacing values generous (32px+) or tight? + Check padding/margin values on root containers, section wrappers, and card components. +- **Density**: Count the components per page/section. Few with space = minimal; + many packed tight = information-dense. +- **Color temperature**: Are the neutrals warm (creams, tans) or cool (blue-grays, slates)? +- **Overall feel**: Synthesize into 1-2 rich sentences that capture the mood. + +Look for these signals in the source: + +| Source Location | What It Tells You | +|:---|:---| +| Root `background-color` or Tailwind `bg-*` on layouts | Overall lightness/darkness | +| Spacing scale in Tailwind config or CSS vars | Whitespace philosophy | +| Number of components vs. wrapper padding | Density | +| Custom property naming (`--warm-*` vs `--cool-*`) | Color temperature intent | +| Comments in theme files | Design intent in the developer's own words | + +#### 2. Color Palette & Roles + +Extract every unique color from the codebase and assign functional roles. +Search across all layers: + +**Where to find colors:** + +| Layer | What to Search | +|:---|:---| +| CSS custom properties | `--color-*`, `--primary`, `--bg-*` | +| Tailwind config | `theme.extend.colors` | +| Theme/token files | Color objects, palettes | +| Component styles | `background-color`, `color`, `border-color` | +| Inline/scoped styles | `bg-*`, `text-*` classes in templates | +| CSS-in-JS theme objects | `colors`, `palette` keys | + +**How to organize:** Group colors by function, not by hue: + +1. **Primary Foundation** — Background and surface colors +2. **Accent & Interactive** — CTA buttons, active states, links +3. **Typography & Text Hierarchy** — Primary, secondary, tertiary text +4. **Functional States** — Success, error, warning, info + +For each color, create a descriptive name that evokes the color's character +rather than its raw hex value: + +- ❌ `#294056` → "Blue" +- ✅ `#294056` → **"Deep Muted Teal-Navy"** — Primary CTA, active navigation + +**Deduplication matters.** Codebases often have near-duplicate colors (e.g., +`#333` and `#2C2C2C`). Consolidate them under one name that best represents +the intended color. + +#### 3. Typography Rules + +Extract the complete typographic system: + +**Font families:** +- Check CSS `font-family`, Tailwind `fontFamily`, Google Fonts links, or + local `@font-face` declarations. +- Note the **character** of each font: geometric vs humanist, serif vs sans, + the feeling it evokes. + +**Type scale (hierarchy):** +- Find every heading level (H1-H6) and body text, noting: + - `font-size` (in rem or px) + - `font-weight` (numeric value + descriptive name) + - `letter-spacing` (and why — elegance? compactness?) + - `line-height` (generous for readability? tight for display?) +- Map component usage: Which heading level do product cards use? What about + hero sections? + +**Spacing principles:** +- How does text spacing relate to the overall spacing scale? +- Letter-spacing patterns on headings vs body +- Line-height philosophy (generous/relaxed for body, tighter for display) + +#### 4. Component Stylings + +Analyze the 4-5 most important UI primitives: + +**Buttons:** +- Corner radius (and what it communicates — playful? professional? minimal?) +- Color scheme for primary, secondary, and ghost variants +- Hover/focus/active states and transition timing +- Padding ratios (horizontal vs vertical) + +**Cards / Containers:** +- Corner radius (often different from buttons — slightly rounder) +- Shadow strategy: flat, subtle hover shadows, or always elevated? +- Border treatment: hairline borders, colored accents, or none? +- Internal padding (generous or compact?) +- Image treatment within cards (full-bleed, padded, rounded?) + +**Navigation:** +- Layout pattern (horizontal bar, vertical sidebar, drawer) +- Typography treatment (uppercase, letter-spacing, weight) +- Active/hover state indicators (underline, color, background) +- Mobile behavior (hamburger, bottom nav, drawer) + +**Inputs & Forms:** +- Border style and focus state behavior +- Corner style consistency with buttons +- Padding and touch-target sizing + +**Domain-Specific Components:** +- Identify 1-2 components unique to this project (e.g., product cards, + dashboard widgets, chat bubbles) and describe their styling patterns. + +#### 5. Layout Principles + +Extract the structural system: + +**Grid & Structure:** +- Max content width (from `max-width` on containers) +- Column system (CSS Grid, Flexbox patterns, defined breakpoints) +- Responsive breakpoints (from media queries or Tailwind config) + +**Whitespace Strategy:** +- Base spacing unit (8px grid? 4px? custom?) +- Section margins (how much space between major sections) +- Edge padding (page margins at different breakpoints) + +**Alignment & Visual Balance:** +- Text alignment patterns (centered heroes, left-aligned body) +- Image-to-text ratios +- Visual weight distribution + +**Responsive Behavior:** +- Mobile-first or desktop-first? +- How do grids collapse? Padding scale? +- Touch target sizing + +#### 6. Stitch Generation Notes + +Synthesize the extraction into actionable prompts for Stitch: + +- **Atmosphere language**: Translate the mood into natural descriptors +- **Color references**: List colors by descriptive name + hex +- **Component prompts**: Write 2-3 example prompts that would recreate + key components in Stitch +- **Iteration guidance**: Tips for refining screens in this design system + +--- + +### Phase 3: Write the DESIGN.md + +Assemble everything into the standard DESIGN.md format. Place it at +`.stitch/DESIGN.md` in the project directory (create the `.stitch/` directory +if it doesn't exist). + +> [!IMPORTANT] +> You **MUST** include the YAML frontmatter at the top of the file with `name` and `colors` mapping, exactly as shown in the example at [examples/DESIGN.md](examples/DESIGN.md). This structured data is required for other skills to parse the design system. +> +> Failure to include this YAML block with at least the core color tokens is a failure to use this skill correctly. + +Use the format from the example at [examples/DESIGN.md](examples/DESIGN.md) as your template. The file must start with the YAML block, followed by the markdown sections: + +```markdown +# Design System: [Project Name] +**Project ID:** [If known, otherwise omit] + +## 1. Visual Theme & Atmosphere +[Rich 2-paragraph description of mood, philosophy, and key characteristics] + +## 2. Color Palette & Roles +### Primary Foundation +### Accent & Interactive +### Typography & Text Hierarchy +### Functional States + +## 3. Typography Rules +### Hierarchy & Weights +### Spacing Principles + +## 4. Component Stylings +### Buttons +### Cards & [Domain-Specific Containers] +### Navigation +### Inputs & Forms +### [Domain-Specific Components] + +## 5. Layout Principles +### Grid & Structure +### Whitespace Strategy +### Alignment & Visual Balance +### Responsive Behavior & Touch + +## 6. Design System Notes for Stitch Generation +### Language to Use +### Color References +### Component Prompts +### Incremental Iteration +``` + +--- + +### Phase 4: Integration (Optional) + +If the user wants to push the design system into Stitch: + +1. Hand off to the `manage-design-system` skill for the MCP create/update calls +2. The DESIGN.md you wrote is the input — the manage-design-system skill handles + the Stitch API integration + +If the user just wants the document, you're done after Phase 3. + +--- + +## Quality Checklist + +Before delivering the DESIGN.md, verify: + +- [ ] Every color has a descriptive name, hex code, and functional role +- [ ] Typography includes font family, character description, and full hierarchy +- [ ] Component styles describe shape, color, states, and transitions +- [ ] Layout includes max-width, grid, breakpoints, and spacing strategy +- [ ] Stitch generation notes use natural language, not CSS syntax +- [ ] The atmosphere section reads like editorial copy, not technical docs +- [ ] Near-duplicate colors are consolidated +- [ ] The document captures the *intent* behind styling, not just raw values + +## Tips for Better Extraction + +- **Read comments and commit messages.** Developers often document design + intent in code comments (`/* hero section — breathable */`) and commit + messages. These are gold for understanding the *why*. +- **Check for design-token libraries.** If the project uses `style-dictionary`, + `@tokens-studio`, or similar, these files are the most authoritative + source of design values. +- **Theme files are higher-signal than component styles.** A `theme.ts` that + defines a palette tells you the intended design system; scattered inline + styles in components tell you what actually shipped. Both matter, but + start from the theme. +- **Tailwind config is a design system.** If a project has a customized + `tailwind.config.js`, that *is* the design system — extract from it first, + then spot-check components for overrides. +- **CSS custom properties are intentional.** If a developer defined + `--brand-primary`, they're telling you this is a design token. Respect that. diff --git a/.agents/skills/stitch-extract-design-md/examples/DESIGN.md b/.agents/skills/stitch-extract-design-md/examples/DESIGN.md new file mode 100644 index 0000000..67be49f --- /dev/null +++ b/.agents/skills/stitch-extract-design-md/examples/DESIGN.md @@ -0,0 +1,210 @@ +--- +name: Alpine Peak +colors: + surface: '#fcf8fa' + surface-dim: '#dcd9db' + surface-bright: '#fcf8fa' + surface-container-lowest: '#ffffff' + surface-container-low: '#f6f3f5' + surface-container: '#f0edef' + surface-container-high: '#eae7e9' + surface-container-highest: '#e4e2e4' + on-surface: '#1b1b1d' + on-surface-variant: '#45464d' + inverse-surface: '#303032' + inverse-on-surface: '#f3f0f2' + outline: '#76777d' + outline-variant: '#c6c6cd' + surface-tint: '#565e74' + primary: '#000000' + on-primary: '#ffffff' + primary-container: '#131b2e' + on-primary-container: '#7c839b' + inverse-primary: '#bec6e0' + secondary: '#5c5f61' + on-secondary: '#ffffff' + secondary-container: '#e0e3e5' + on-secondary-container: '#626567' + tertiary: '#000000' + on-tertiary: '#ffffff' + tertiary-container: '#161c22' + on-tertiary-container: '#7e848c' + error: '#ba1a1a' + on-error: '#ffffff' + error-container: '#ffdad6' + on-error-container: '#93000a' + primary-fixed: '#dae2fd' + primary-fixed-dim: '#bec6e0' + on-primary-fixed: '#131b2e' + on-primary-fixed-variant: '#3f465c' + secondary-fixed: '#e0e3e5' + secondary-fixed-dim: '#c4c7c9' + on-secondary-fixed: '#191c1e' + on-secondary-fixed-variant: '#444749' + tertiary-fixed: '#dde3eb' + tertiary-fixed-dim: '#c1c7cf' + on-tertiary-fixed: '#161c22' + on-tertiary-fixed-variant: '#41474e' + background: '#fcf8fa' + on-background: '#1b1b1d' + surface-variant: '#e4e2e4' +typography: + display-lg: + fontFamily: Inter + fontSize: 48px + fontWeight: '800' + lineHeight: 56px + letterSpacing: -0.02em + headline-md: + fontFamily: Inter + fontSize: 24px + fontWeight: '700' + lineHeight: 32px + letterSpacing: -0.01em + body-base: + fontFamily: Inter + fontSize: 16px + fontWeight: '400' + lineHeight: 24px + letterSpacing: '0' + body-bold: + fontFamily: Inter + fontSize: 16px + fontWeight: '600' + lineHeight: 24px + letterSpacing: '0' + label-caps: + fontFamily: Lexend + fontSize: 12px + fontWeight: '700' + lineHeight: 16px + letterSpacing: 0.05em + stat-lg: + fontFamily: Lexend + fontSize: 32px + fontWeight: '600' + lineHeight: 40px + letterSpacing: -0.02em +rounded: + sm: 0.25rem + DEFAULT: 0.5rem + md: 0.75rem + lg: 1rem + xl: 1.5rem + full: 9999px +spacing: + unit: 4px + xs: 4px + sm: 8px + md: 16px + lg: 24px + xl: 32px + gutter: 16px + margin-mobile: 20px + margin-desktop: 40px +--- + +## Brand & Style + +The brand personality is high-performance, sophisticated, and dependable. It is +designed for the modern skier who demands clarity and precision in harsh +environments. The visual language evokes the crystalline beauty of a mountain +peak while prioritizing the rugged utility required for outdoor navigation. + +The design system utilizes **Glassmorphism** to achieve a "frozen" aesthetic, +mimicking the properties of ice and packed snow. This is paired with a +**High-Contrast** philosophy to ensure that critical data—like trail status and +weather alerts—remains legible under intense mountain sunlight or the +low-visibility conditions of a snowstorm. + +## Colors + +The palette is anchored by **Deep Peak Blue**, a dark navy that provides a +grounding contrast for white text and maps. **Powder White** serves as the +primary canvas, ensuring the interface feels airy and cold. + +**Safety Orange** is reserved strictly for primary calls to action, hazard +warnings, and emergency trail closures, ensuring immediate eye-tracking. +**Electric Blue** (the info status) is used for weather updates and general +interactivity. For trail difficulty levels, standard industry colors (Green, +Blue, Black) are used but rendered with high-saturation values to pop against +the white backgrounds. + +## Typography + +The typography system uses **Inter** for core functional text and body copy due +to its exceptional x-height and legibility at small sizes. **Lexend** is +introduced for labels and statistics; its hyper-readable, athletic character +suits the "active" nature of skiing data like speed and vertical drop. + +To combat outdoor glare, font weights are generally heavier than standard web +applications. Display styles use tight tracking and heavy weights to create a +sense of strength. Labels always utilize high-contrast coloring against their +backgrounds. + +## Layout & Spacing + +This design system uses a **Fluid Grid** model with generous safe areas. +Elements are spaced using a strict 4px/8px baseline rhythm to maintain a +disciplined, professional appearance. + +Touch targets are intentionally oversized (minimum 48x48px) to accommodate users +wearing gloves. Padding within cards and containers is kept spacious (`lg` or +`xl`) to prevent the interface from feeling cluttered when displaying complex +trail data. + +## Elevation & Depth + +Depth is conveyed through **Glassmorphism** and backdrop filters rather than +traditional heavy shadows. Surfaces use a hierarchy of transparency: + +1. **Base Layer:** Solid 'Powder White' or 'Deep Peak Blue'. +2. **Middle Layer (Cards/Modals):** 70% opacity white with a 20px backdrop blur + and a thin 1px 'Ice' border (white at 40% opacity). +3. **Top Layer (Floating Actions):** 90% opacity with a subtle 4px ambient + shadow to separate the element from the blurred background. + +This creates a "stacked ice" effect that maintains visual clarity while giving +the UI a premium, modern feel. + +## Shapes + +The shape language is defined by modern, organic **rounded corners**. This +softens the high-contrast color palette and makes the app feel approachable. + +Interactive elements like buttons and chips utilize `rounded-lg` (1rem) or +`rounded-xl` (1.5rem) to suggest a tactile, "pebble" feel. Large containers like +map overlays use 1rem corners, while small status indicators for trail +difficulty may use pill shapes for instant recognition. + +## Components + +### Buttons + +Primary buttons use a solid 'Safety Orange' or 'Deep Peak Blue' fill with white +text. High-contrast outlines are used for secondary actions. All buttons must +feature a minimum height of 52px for gloved-hand accessibility. + +### Cards & Modals + +Cards utilize the glassmorphism effect—semi-transparent backgrounds with a 1px +white border. This ensures that map imagery or photos underneath remain slightly +visible, maintaining the "frozen" aesthetic. + +### Trail Status Chips + +Pill-shaped indicators with high-saturation icons. Green (Easy), Blue +(Intermediate), Black (Expert), and Double Black (Extreme). These must include +text labels alongside icons to ensure accessibility for colorblind users. + +### Input Fields + +Fields feature a 'Cloud Gray' background with a thick bottom border that +transforms into 'Electric Blue' on focus. Labels are always visible above the +field to ensure the user doesn't lose context in bright environments. + +### Weather Widgets + +Bold, thick-stroke iconography (minimum 2pt stroke) to represent sun, snow, or +wind. These icons should be large and accompanied by 'Lexend' typography for +temperature and wind speed. \ No newline at end of file diff --git a/.agents/skills/stitch-extract-design-md/references/angular.md b/.agents/skills/stitch-extract-design-md/references/angular.md new file mode 100644 index 0000000..65b81fc --- /dev/null +++ b/.agents/skills/stitch-extract-design-md/references/angular.md @@ -0,0 +1,114 @@ +# Angular Extraction Patterns + +Angular projects use a structured, convention-heavy approach to styling. +Design systems often live in SCSS/CSS files with clear separation between +global themes and component-scoped styles. + +## File Discovery Order + +1. **`angular.json`** — Lists global style files under + `projects.*.architect.build.options.styles`. These are the entry-point + CSS/SCSS files. + +2. **`src/styles.scss` / `src/styles.css`** — Global stylesheet. CSS custom + properties, font imports, and base styles live here. + +3. **`src/theme.scss` / `src/theme/`** — Explicit theme directory. Custom + Material/component palettes. + +4. **`tailwind.config.js`** (if Tailwind) — Same extraction as React. + +5. **`src/app/app.component.scss`** — Root component styles, reveals global + layout patterns. + +6. **Component `.scss` / `.css` files** — Co-located styles (ViewEncapsulation + scoped by default). + +## Angular Material Theme Extraction + +Angular Material is the most common component library. Themes are SCSS-based: + +```scss +// src/theme.scss +@use '@angular/material' as mat; + +$primary-palette: mat.m2-define-palette(mat.$m2-teal-palette, 800); +$accent-palette: mat.m2-define-palette(mat.$m2-blue-grey-palette); +$warn-palette: mat.m2-define-palette(mat.$m2-red-palette); + +$theme: mat.m2-define-light-theme(( + color: ( + primary: $primary-palette, + accent: $accent-palette, + warn: $warn-palette, + ), + typography: mat.m2-define-typography-config( + $font-family: 'Manrope, sans-serif', + $headline-1: mat.m2-define-typography-level(3.5rem, 4rem, 600), + $headline-5: mat.m2-define-typography-level(1.5rem, 2rem, 500), + $body-1: mat.m2-define-typography-level(1rem, 1.7, 400), + ), +)); + +@include mat.all-component-themes($theme); +``` + +**What to extract:** +- Palette choices → map to functional color roles +- Typography config → maps directly to the hierarchy section +- Light vs dark theme → atmosphere + +For Angular Material 3 (MDC-based), look for `mat.define-theme()` using +the new token system with `--mat-*` CSS custom properties. + +## SCSS Variable Patterns + +Many Angular projects use SCSS variables for tokens: + +```scss +// _variables.scss +$color-primary: #294056; +$color-background: #FCFAFA; +$color-surface: #F5F5F5; +$color-text: #2C2C2C; + +$font-heading: 'Manrope', sans-serif; +$font-body: 'Inter', sans-serif; + +$radius-button: 8px; +$radius-card: 12px; + +$breakpoint-mobile: 768px; +$breakpoint-desktop: 1024px; + +$spacing-section: 5rem; +$spacing-component: 2rem; +``` + +These are explicit design tokens. Map them directly. + +## ViewEncapsulation and Scoped Styles + +Angular scopes styles by default (similar to Vue's `scoped`). When scanning +component styles: + +- Look for `:host` selectors — these style the component's root element +- `::ng-deep` (deprecated but still used) — styles that pierce encapsulation +- Repeated values across components indicate design system conventions + +## PrimeNG / Nebular / NG-ZORRO + +If component libraries are used: +- **PrimeNG**: Theme SCSS in `node_modules/primeng/resources/themes/` — + look for custom theme or `styles.scss` overrides. +- **Nebular**: `nb-theme()` in `styles.scss` with custom theme object. +- **NG-ZORRO (Ant Design for Angular)**: `ng-zorro-antd.less` variables + or custom theme config in `angular.json`. + +## Responsive Patterns + +Check for: +- `@media` queries in `styles.scss` and component styles +- Angular CDK `BreakpointObserver` usage in components +- Tailwind responsive prefixes if Tailwind is configured +- Angular Flex-Layout directives (`fxLayout`, `fxFlex`) in templates diff --git a/.agents/skills/stitch-extract-design-md/references/plain-css.md b/.agents/skills/stitch-extract-design-md/references/plain-css.md new file mode 100644 index 0000000..6bea95b --- /dev/null +++ b/.agents/skills/stitch-extract-design-md/references/plain-css.md @@ -0,0 +1,161 @@ +# Plain CSS / SASS / Less Extraction Patterns + +For projects without a JavaScript framework — static sites, WordPress +themes, vanilla HTML/CSS, or CSS preprocessor-heavy projects. + +## File Discovery Order + +1. **`index.html` / `*.html`** — Check `` tags and ` +``` + +**Extraction points:** +- Component props (like `variant`) reveal the intended variant system +- `var(--*)` references → trace to `app.css` +- Transition values reveal the interaction design philosophy + +## SvelteKit Layout Patterns + +- **`+layout.svelte`** at route root — Global header, footer, font loading +- **`+layout.ts/js`** — May load theme data or tokens +- **`$lib/`** directory — Reusable components and shared utilities + +## CSS Custom Properties Strategy + +Svelte projects heavily use CSS custom properties for theming: + +```css +/* app.css */ +:root { + --color-primary: #294056; + --color-bg: #FCFAFA; + --color-surface: #F5F5F5; + --color-text: #2C2C2C; + --color-text-muted: #6B6B6B; + + --font-heading: 'Manrope', sans-serif; + --font-body: 'Inter', sans-serif; + + --radius-sm: 8px; + --radius-md: 12px; + --radius-full: 9999px; + + --shadow-hover: 0 2px 8px rgba(0,0,0,0.06); + --spacing-section: 5rem; +} +``` + +These variable names are highly intentional. Use them as the foundation +of your design system extraction. + +## Skeleton UI / DaisyUI / Flowbite-Svelte + +If component libraries are used: +- **Skeleton UI**: Theme defined in `tailwind.config.js` using Skeleton's + design token system. Look for custom theme config object. +- **DaisyUI**: Theme in `tailwind.config.js` → `daisyui.themes` array. +- **Flowbite-Svelte**: Standard Tailwind theming. diff --git a/.agents/skills/stitch-extract-design-md/references/vue.md b/.agents/skills/stitch-extract-design-md/references/vue.md new file mode 100644 index 0000000..1412288 --- /dev/null +++ b/.agents/skills/stitch-extract-design-md/references/vue.md @@ -0,0 +1,107 @@ +# Vue / Nuxt Extraction Patterns + +Vue projects have a distinctive styling architecture. Styles are often +co-located with components inside ` +``` + +**Key extraction points:** +- `var(--*)` references → trace back to global CSS for the actual values +- `scoped` styles → component-specific, but reveal consistent patterns +- BEM naming (`.card__title`) → hints at component hierarchy + +## Vuetify Theme Extraction + +Vuetify projects define their design system explicitly: + +```ts +// plugins/vuetify.ts +export default createVuetify({ + theme: { + defaultTheme: 'light', + themes: { + light: { + colors: { + primary: '#294056', + secondary: '#6B6B6B', + background: '#FCFAFA', + surface: '#F5F5F5', + error: '#EF4444', + success: '#10B981', + } + } + } + } +}) +``` + +This is the design system declaration. Map each key to a functional role +and descriptive name. + +## Quasar / PrimeVue / Element Plus + +These component libraries use their own theming systems: + +- **Quasar**: `quasar.config.js` → `framework.config.brand` for colors +- **PrimeVue**: CSS themes in `assets/` or theme preset configuration +- **Element Plus**: SCSS variables in `element-variables.scss` + +Look for the override file — that's where the project's unique values live. + +## CSS Scoping Behavior + +Vue's `scoped` attribute adds data attributes for CSS isolation. When +scanning for patterns, look at multiple components to find repeated values +(same `border-radius`, similar `padding`, consistent color references). +Repeated patterns across scoped styles = design system conventions. + +## Nuxt-Specific Patterns + +- **`app.vue`** or **`layouts/default.vue`** — Root layout, reveals + global background, font loading, and overall structure. +- **`assets/`** — Global CSS, fonts, and images. +- **`composables/`** — May contain `useTheme` or `useDesignTokens`. +- **`nuxt.config.ts`** `css` array — Lists global stylesheets automatically + injected into every page. diff --git a/.agents/skills/stitch-extract-static-html/SKILL.md b/.agents/skills/stitch-extract-static-html/SKILL.md new file mode 100644 index 0000000..5b627b4 --- /dev/null +++ b/.agents/skills/stitch-extract-static-html/SKILL.md @@ -0,0 +1,182 @@ +--- +name: stitch::extract-static-html +description: >- + Extract self-contained static HTML from a built web application or React components by inlining CSS and images. Use this skill whenever you need to capture a specific UI state, share a static version of a page, or prepare assets for Stitch upload, even if the user just asks to 'save the HTML' or 'mock the view'. +allowed-tools: + - "stitch*:*" + - "Bash" + - "Read" + - "Write" + - "web_fetch" +--- + +# Extract Static HTML + +Extract a self-contained static HTML file from any web application. + +## Which Strategy to Use + +You MUST ask the user to choose which strategy to use before proceeding. Present the options clearly, **recommend Strategy A** as the preferred default, and **provide a brief pros/cons summary** for each option to help them make an informed decision. + +| | Strategy A (Puppeteer) | Strategy B (Browser Subagent) | +| :--- | :--- | :--- | +| **When** | App runs locally, no auth wall | Need to interact with page first (click, fill forms) | +| **Fidelity** | **Highest — computed styles resolved** | High — rendered DOM | +| **Setup** | **Zero — no mock needed** | Zero — no mock needed | +| **Framework** | **Any** | Any | +| **Output** | **Writes to file — no size limit** | May truncate in agent context | + +> [!WARNING] +> **Checkpoint — User Confirmation Required.** +> You **MUST** ask the user which strategy they prefer before proceeding. +> Present the comparison table above, recommend Strategy A as the default, and +> wait for explicit approval. Do **NOT** make the decision yourself or proceed +> until the user confirms. + +*** + +## Strategy A: Puppeteer Snapshot (Recommended) + +Launches headless Chrome, captures the fully rendered DOM, and produces a self-contained HTML file with all CSS inlined and images as base64. Works with **any framework** — no MockPage.jsx needed. + +### Prerequisites + +- App running locally (e.g., `npm run dev`) +- Node.js with `puppeteer` available (check: `node -e "require('puppeteer')"`) + +### Workflow + +1. **Start the App** and note the port. + + > [!WARNING] + > **Checkpoint — User Confirmation Required.** + > After starting the local server, you **MUST** pause and ask the user for + > confirmation before running the snapshot script or launching a browser + > subagent. Report the URL and port to the user so they can verify the app + > is running and rendering correctly. Do **NOT** proceed to the snapshot + > step until the user confirms. + +2. **Run the Snapshot Script**: + ```bash + npx tsx /scripts/snapshot.ts \ + --url http://localhost:5173 \ + --output .stitch/home.html \ + --wait 2000 + ``` + +3. **Multiple pages** — run once per route: + ```bash + npx tsx /scripts/snapshot.ts \ + --url http://localhost:5173 --output .stitch/home.html --wait 2000 + npx tsx /scripts/snapshot.ts \ + --url http://localhost:5173/pricing --output .stitch/pricing.html --wait 2000 + npx tsx /scripts/snapshot.ts \ + --url http://localhost:5173/dashboard --output .stitch/dashboard.html --wait 2000 --html-class dark + ``` + +### Script Flags + +| Flag | Default | Description | +| :--- | :--- | :--- | +| `--url` | *(required)* | URL to capture | +| `--output` | *(required)* | Output file path | +| `--wait` | `1000` | Extra wait (ms) after network idle. Increase for lazy-loading apps. | +| `--viewport` | `1280x800` | Viewport size as `WIDTHxHEIGHT` | +| `--html-class` | — | Class(es) for `` element (e.g., `dark`) | +| `--remove-fixed` | `false` | Remove fixed/sticky elements (cookie banners, chat widgets) | +| `--full-height` | `false` | Resize viewport to full scroll height | +| `--title` | — | Override page title | + +### What It Does Automatically + +- Inlines all `` → `\n\n`; + + return head; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- +async function main(): Promise { + const opts = parseArgs(); + validateOpts(opts); + + const startTime = Date.now(); + const head = buildHead(opts); + const stats: AllStats = { + pages: [], + totalImages: 0, + durationMs: 0, + warnings: [], + }; + + fs.mkdirSync(opts.outdir, { recursive: true }); + + for (const spec of opts.pages) { + const parts = spec.split(':'); + const [src, dstName, title] = parts; + const dst = path.join(opts.outdir, dstName); + + console.log(`\n${'='.repeat(60)}`); + console.log(`Converting ${src} -> ${dstName}...`); + console.log(`${'='.repeat(60)}`); + + const jsx = fs.readFileSync(src, 'utf-8'); + let body = jsxToHtml(jsx); + + if (!body) { + const msg = `Failed to parse JSX from ${src}`; + console.error(` ${msg}`); + stats.warnings.push(msg); + continue; + } + + // Apply exclude pattern (pre-validated during argument parsing) + if (opts.excludePattern) { + body = body.replace(opts.excludePattern, ''); + } + + // Extract body class from outer wrapper div + const outerMatch = body.match(/^]*>([\s\S]*)<\/div>$/); + let fullHtml: string; + if (outerMatch) { + fullHtml = head.replace('{{title}}', title) + + `\n${outerMatch[2].trim()}\n\n`; + } else { + fullHtml = head.replace('{{title}}', title) + + `\n${body}\n\n`; + } + + // Embed remote images with concurrency + const cacheCountBefore = imgCache.size; + fullHtml = await embedImages(fullHtml, opts.concurrency, opts.timeout); + const imagesEmbedded = imgCache.size - cacheCountBefore; + + fs.writeFileSync(dst, fullHtml, 'utf-8'); + const fileSize = fs.statSync(dst).size; + console.log(`=> ${dst} (${fileSize.toLocaleString()} bytes)`); + + stats.pages.push({ + src, + dst, + sizeBytes: fileSize, + imagesEmbedded, + }); + } + + stats.totalImages = imgCache.size; + stats.durationMs = Date.now() - startTime; + + console.log( + `\nDONE: ${imgCache.size} unique images embedded in ${stats.durationMs}ms.`, + ); + + if (opts.json) { + console.log('\n--- JSON Stats ---'); + console.log(JSON.stringify(stats, null, 2)); + } +} + +main().catch((err: Error) => { + console.error('❌ Error:', err.message); + if (err.stack) console.error(err.stack); + process.exit(1); +}); diff --git a/.agents/skills/stitch-extract-static-html/scripts/post_process.ts b/.agents/skills/stitch-extract-static-html/scripts/post_process.ts new file mode 100644 index 0000000..0facbc7 --- /dev/null +++ b/.agents/skills/stitch-extract-static-html/scripts/post_process.ts @@ -0,0 +1,498 @@ +#!/usr/bin/env npx tsx +/** + * post_process.ts — Inline local images as base64 in extracted HTML files. + * + * Scans HTML files for local image references (src attributes and CSS url() + * values) and replaces them with inline base64 data URIs. Uses a robust + * character-by-character CSS url() parser instead of regex. + * + * Usage: + * npx tsx post_process.ts .stitch/home.html --base-dir my-app + * npx tsx post_process.ts .stitch/page1.html .stitch/page2.html --base-dir . + * npx tsx post_process.ts .stitch/*.html --base-dir . --json + * + * Flags: + * --base-dir Base directory for resolving relative paths + * --json Output machine-readable JSON stats + * --dry-run Report what would be inlined without modifying files + * --max-size Max file size to inline in bytes (default: 5242880 / 5MB) + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +// --------------------------------------------------------------------------- +// MIME type mapping +// --------------------------------------------------------------------------- +const MIME_MAP: Record = { + '.svg': 'image/svg+xml', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.bmp': 'image/bmp', + '.avif': 'image/avif', + '.tiff': 'image/tiff', + '.tif': 'image/tiff', + '.apng': 'image/apng', + '.cur': 'image/x-icon', +}; + +function getMime(filePath: string): string { + return MIME_MAP[path.extname(filePath).toLowerCase()] || 'application/octet-stream'; +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- +interface Opts { + files: string[]; + baseDir: string; + json: boolean; + dryRun: boolean; + maxSize: number; +} + +interface CssUrlRef { + url: string; + fullMatch: string; + start: number; + end: number; +} + +interface InlineStats { + srcInlined: number; + urlInlined: number; + skippedTooLarge: Array<{ path: string; size: number }>; + skippedNotFound: string[]; +} + +interface FileStats { + file: string; + srcInlined: number; + urlInlined: number; + skippedNotFound: number; + skippedTooLarge: number; + sizeBytes: number; +} + +interface AllStats { + files: FileStats[]; + totalSrcInlined: number; + totalUrlInlined: number; + totalSkippedNotFound: number; + totalSkippedTooLarge: number; +} + +// --------------------------------------------------------------------------- +// Argument parsing & validation +// --------------------------------------------------------------------------- +function parseArgs(): Opts { + const args = process.argv.slice(2); + const opts: Opts = { + files: [], + baseDir: '', + json: false, + dryRun: false, + maxSize: 5 * 1024 * 1024, // 5MB + }; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--base-dir': + opts.baseDir = args[++i]; + break; + case '--json': + opts.json = true; + break; + case '--dry-run': + opts.dryRun = true; + break; + case '--max-size': + opts.maxSize = parseInt(args[++i], 10); + break; + case '--help': + console.log(` +Usage: npx tsx post_process.ts [...] [options] + +Options: + --base-dir Base directory for resolving relative paths + --json Output machine-readable JSON stats + --dry-run Report what would be inlined without modifying files + --max-size Max file size to inline in bytes (default: 5242880 / 5MB) +`); + process.exit(0); + default: + opts.files.push(args[i]); + } + } + + return opts; +} + +function validateOpts(opts: Opts): void { + const errors: string[] = []; + + if (opts.files.length === 0) { + errors.push('No HTML files specified'); + } + + if (opts.baseDir && !fs.existsSync(opts.baseDir)) { + errors.push(`Base directory not found: ${opts.baseDir}`); + } + + if (isNaN(opts.maxSize) || opts.maxSize < 1) { + errors.push('--max-size must be a positive integer'); + } + + if (errors.length > 0) { + console.error('❌ Validation errors:'); + errors.forEach((e) => console.error(` • ${e}`)); + process.exit(1); + } +} + +// --------------------------------------------------------------------------- +// Robust CSS url() parser — character-by-character (no regex) +// --------------------------------------------------------------------------- +function extractCssUrls(text: string): CssUrlRef[] { + const results: CssUrlRef[] = []; + let i = 0; + const len = text.length; + + while (i < len) { + if ( + i + 3 < len && + text[i].toLowerCase() === 'u' && + text[i + 1].toLowerCase() === 'r' && + text[i + 2].toLowerCase() === 'l' && + text[i + 3] === '(' + ) { + const urlStart = i; + i += 4; + + // Skip whitespace + while (i < len && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n' || text[i] === '\r')) i++; + + let quote: string | null = null; + if (i < len && (text[i] === '"' || text[i] === "'")) { + quote = text[i]; + i++; + } + + let url = ''; + if (quote) { + while (i < len && text[i] !== quote) { + if (text[i] === '\\' && i + 1 < len) { + i++; + url += text[i]; + } else { + url += text[i]; + } + i++; + } + if (i < len) i++; + } else { + while (i < len && text[i] !== ')' && text[i] !== ' ' && text[i] !== '\t' && text[i] !== '\n') { + url += text[i]; + i++; + } + } + + while (i < len && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n' || text[i] === '\r')) i++; + + if (i < len && text[i] === ')') { + const fullMatch = text.substring(urlStart, i + 1); + results.push({ url: url.trim(), fullMatch, start: urlStart, end: i + 1 }); + i++; + } else { + i = urlStart + 1; + } + } else { + i++; + } + } + + return results; +} + +// --------------------------------------------------------------------------- +// Local path resolution +// --------------------------------------------------------------------------- +function resolveLocalFile(localPath: string, baseDir: string): string | null { + const candidates = [localPath]; + if (baseDir) { + candidates.push(path.join(baseDir, localPath.replace(/^\//, ''))); + } + + for (const candidate of candidates) { + try { + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + return candidate; + } + } catch { + // Permission errors, etc. — skip + } + } + return null; +} + +/** + * Atomically open, stat, and read a file using a file descriptor. + * Eliminates TOCTOU race conditions by performing all operations on the + * same fd, ensuring the file cannot change between the size check and read. + * Returns null if the file cannot be opened (e.g., deleted between resolve and open). + */ +function readFileAtomic( + filePath: string, + maxSize: number, +): { size: number; mime: string; b64: string } | { size: number; tooLarge: true } | null { + let fd: number; + try { + fd = fs.openSync(filePath, 'r'); + } catch { + // File was removed or became inaccessible between resolve and open + return null; + } + try { + const stat = fs.fstatSync(fd); + if (stat.size > maxSize) { + return { size: stat.size, tooLarge: true }; + } + const mime = getMime(filePath); + const b64 = fs.readFileSync(fd).toString('base64'); + return { size: stat.size, mime, b64 }; + } finally { + fs.closeSync(fd); + } +} + +/** + * Check if a path is a local (non-remote, non-data) reference. + */ +function isLocalPath(url: string): boolean { + return ( + !!url && + !url.startsWith('http://') && + !url.startsWith('https://') && + !url.startsWith('data:') && + !url.startsWith('//') + ); +} + +// --------------------------------------------------------------------------- +// Inline images in HTML +// --------------------------------------------------------------------------- +function inlineImages( + html: string, + baseDir: string, + maxSize: number, + dryRun: boolean, +): { html: string; stats: InlineStats } { + const stats: InlineStats = { + srcInlined: 0, + urlInlined: 0, + skippedTooLarge: [], + skippedNotFound: [], + }; + + // --- Inline src="" attributes --- + // Handle src, poster, data attributes + const srcAttrs = ['src', 'poster', 'data']; + for (const attr of srcAttrs) { + const regex = new RegExp(`${attr}="((?!https?:\\/\\/|data:|\\/\\/)[^"]+)"`, 'g'); + html = html.replace(regex, (match: string, localPath: string) => { + const resolved = resolveLocalFile(localPath, baseDir); + if (!resolved) { + if (!localPath.endsWith('.js') && !localPath.endsWith('.css')) { + stats.skippedNotFound.push(localPath); + } + return match; + } + + const result = readFileAtomic(resolved, maxSize); + if (!result) { + stats.skippedNotFound.push(localPath); + return match; + } + if ('tooLarge' in result) { + stats.skippedTooLarge.push({ path: localPath, size: result.size }); + return match; + } + + if (dryRun) { + stats.srcInlined++; + return match; + } + + stats.srcInlined++; + return `${attr}="data:${result.mime};base64,${result.b64}"`; + }); + } + + // --- Inline CSS url() with local paths (using robust parser) --- + const urlRefs = extractCssUrls(html); + const localUrlRefs = urlRefs.filter((ref) => isLocalPath(ref.url)); + + // Process from end to preserve indices + const sorted = [...localUrlRefs].sort((a, b) => b.start - a.start); + for (const ref of sorted) { + const resolved = resolveLocalFile(ref.url, baseDir); + if (!resolved) { + stats.skippedNotFound.push(ref.url); + continue; + } + + const result = readFileAtomic(resolved, maxSize); + if (!result) { + stats.skippedNotFound.push(ref.url); + continue; + } + if ('tooLarge' in result) { + stats.skippedTooLarge.push({ path: ref.url, size: result.size }); + continue; + } + + if (dryRun) { + stats.urlInlined++; + continue; + } + + html = + html.substring(0, ref.start) + + `url('data:${result.mime};base64,${result.b64}')` + + html.substring(ref.end); + stats.urlInlined++; + } + + // --- Inline SVG and xlink:href --- + const svgHrefRegex = /(href|xlink:href)="((?!https?:\/\/|data:|\/\/)[^"]+)"/g; + html = html.replace(svgHrefRegex, (match: string, attrName: string, localPath: string) => { + // Skip non-image hrefs (like ) + if (!localPath.match(/\.(svg|png|jpg|jpeg|gif|webp|avif|bmp|ico)$/i)) { + return match; + } + + const resolved = resolveLocalFile(localPath, baseDir); + if (!resolved) { + stats.skippedNotFound.push(localPath); + return match; + } + + const result = readFileAtomic(resolved, maxSize); + if (!result) { + stats.skippedNotFound.push(localPath); + return match; + } + if ('tooLarge' in result) { + stats.skippedTooLarge.push({ path: localPath, size: result.size }); + return match; + } + + if (dryRun) { + stats.srcInlined++; + return match; + } + + stats.srcInlined++; + return `${attrName}="data:${result.mime};base64,${result.b64}"`; + }); + + return { html, stats }; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- +function main(): void { + const opts = parseArgs(); + validateOpts(opts); + + const allStats: AllStats = { + files: [], + totalSrcInlined: 0, + totalUrlInlined: 0, + totalSkippedNotFound: 0, + totalSkippedTooLarge: 0, + }; + + if (opts.dryRun) { + console.log('🔍 DRY RUN — no files will be modified\n'); + } + + for (const file of opts.files) { + // Open file once with r+ to eliminate TOCTOU race between read and write. + // A single fd is used for both operations, so the file cannot be swapped + // between the read and write phases. + let fd: number; + try { + fd = fs.openSync(file, opts.dryRun ? 'r' : 'r+'); + } catch { + console.warn(`⚠️ File not found, skipping: ${file}`); + continue; + } + + let processed: string = ''; + let stats: InlineStats = { srcInlined: 0, urlInlined: 0, skippedTooLarge: [], skippedNotFound: [] }; + try { + const html = fs.readFileSync(fd, 'utf-8'); + + const result = inlineImages(html, opts.baseDir, opts.maxSize, opts.dryRun); + processed = result.html; + stats = result.stats; + + if (!opts.dryRun) { + // Truncate and rewrite using the same fd — no second path-based open + fs.ftruncateSync(fd); + fs.writeSync(fd, processed, 0, 'utf-8'); + } + } finally { + fs.closeSync(fd); + } + + const totalInlined = stats.srcInlined + stats.urlInlined; + const label = opts.dryRun ? 'would inline' : 'inlined'; + console.log( + `${file}: ${label} ${totalInlined} resources ` + + `(${stats.srcInlined} src, ${stats.urlInlined} url()) ` + + `— ${processed.length.toLocaleString()} bytes`, + ); + + if (stats.skippedTooLarge.length > 0) { + for (const s of stats.skippedTooLarge) { + console.log( + ` ⚠️ Skipped (too large: ${(s.size / 1024).toFixed(1)} KB): ${s.path}`, + ); + } + } + + allStats.files.push({ + file, + srcInlined: stats.srcInlined, + urlInlined: stats.urlInlined, + skippedNotFound: stats.skippedNotFound.length, + skippedTooLarge: stats.skippedTooLarge.length, + sizeBytes: processed.length, + }); + allStats.totalSrcInlined += stats.srcInlined; + allStats.totalUrlInlined += stats.urlInlined; + allStats.totalSkippedNotFound += stats.skippedNotFound.length; + allStats.totalSkippedTooLarge += stats.skippedTooLarge.length; + } + + const totalInlined = allStats.totalSrcInlined + allStats.totalUrlInlined; + console.log(`\n✅ Total: ${totalInlined} resources inlined across ${allStats.files.length} file(s)`); + + if (allStats.totalSkippedTooLarge > 0) { + console.log(` ⚠️ ${allStats.totalSkippedTooLarge} skipped (exceeded ${(opts.maxSize / 1024 / 1024).toFixed(1)} MB limit)`); + } + + if (opts.json) { + console.log('\n--- JSON Stats ---'); + console.log(JSON.stringify(allStats, null, 2)); + } +} + +main(); diff --git a/.agents/skills/stitch-extract-static-html/scripts/snapshot.ts b/.agents/skills/stitch-extract-static-html/scripts/snapshot.ts new file mode 100644 index 0000000..cdbf4d5 --- /dev/null +++ b/.agents/skills/stitch-extract-static-html/scripts/snapshot.ts @@ -0,0 +1,1329 @@ +#!/usr/bin/env npx tsx +/** + * snapshot.ts — Production-grade Puppeteer-based full-page HTML snapshot + * + * Captures the fully rendered DOM from a running web application and produces + * a self-contained HTML file with all CSS inlined and images converted to + * base64 data URIs. Works with any framework (React, Vue, Svelte, Angular, + * plain HTML, etc.) — no MockPage.jsx needed. + * + * Usage: + * npx tsx snapshot.ts --url http://localhost:5173 --output .stitch/home.html + * npx tsx snapshot.ts --url http://localhost:3000/pricing --output .stitch/pricing.html --html-class dark + * npx tsx snapshot.ts --url http://localhost:5173 --output .stitch/page.html --wait 5000 --viewport 1440x900 + * + * Flags: + * --url URL to capture (required) + * --output Output file path (required) + * --wait Extra wait time in ms after network idle (default: 1000) + * --viewport Viewport size as WIDTHxHEIGHT (default: 1280x800) + * --html-class Class(es) to add to element (e.g., "dark") + * --remove-fixed Remove fixed/sticky positioned elements (e.g., cookie banners) + * --full-height Capture full scrollable content by resizing viewport to scrollHeight + * --title Override the page title + * --timeout Global timeout in ms (default: 60000) + * --concurrency Max concurrent resource fetches (default: 6) + * --json Output machine-readable JSON stats to stdout + */ + +import puppeteer, { type Browser } from 'puppeteer'; +import path from 'node:path'; +import fs from 'node:fs'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- +interface Opts { + url: string | null; + output: string | null; + wait: number; + viewport: string; + htmlClass: string | null; + removeFixed: boolean; + fullHeight: boolean; + title: string | null; + timeout: number; + concurrency: number; + json: boolean; + inlineFonts: boolean; + removeSelectors: string | null; + click: string | null; +} + +interface Stats { + url: string | null; + output: string | null; + sizeBytes: number; + stylesheets: number; + images: number; + cssUrls: number; + svgImages: number; + videoPoster: number; + favicons: number; + scriptsRemoved: number; + warnings: string[]; + durationMs: number; + error?: string; +} + +// --------------------------------------------------------------------------- +// Argument parsing +// --------------------------------------------------------------------------- +function parseArgs(): Opts { + const args = process.argv.slice(2); + const opts: Opts = { + url: null, + output: null, + wait: 1000, + viewport: '1280x800', + htmlClass: null, + removeFixed: false, + fullHeight: false, + title: null, + timeout: 60000, + concurrency: 6, + json: false, + inlineFonts: false, + removeSelectors: null, + click: null, + }; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--url': + opts.url = args[++i]; + break; + case '--output': + opts.output = args[++i]; + break; + case '--wait': + opts.wait = parseInt(args[++i], 10); + break; + case '--viewport': + opts.viewport = args[++i]; + break; + case '--html-class': + opts.htmlClass = args[++i]; + break; + case '--remove-fixed': + opts.removeFixed = true; + break; + case '--full-height': + opts.fullHeight = true; + break; + case '--title': + opts.title = args[++i]; + break; + case '--timeout': + opts.timeout = parseInt(args[++i], 10); + break; + case '--concurrency': + opts.concurrency = parseInt(args[++i], 10); + break; + case '--json': + opts.json = true; + break; + case '--inline-fonts': + opts.inlineFonts = true; + break; + case '--remove-selectors': + opts.removeSelectors = args[++i]; + break; + case '--click': + opts.click = args[++i]; + break; + case '--help': + console.log(` +Usage: npx tsx snapshot.ts --url --output [options] + +Options: + --url URL to capture (required) + --output Output file path (required) + --wait Extra wait time in ms after network idle (default: 1000) + --viewport Viewport size as WIDTHxHEIGHT (default: 1280x800) + --html-class Class(es) to add to element (e.g., "dark") + --remove-fixed Remove fixed/sticky positioned elements (cookie banners, etc.) + --full-height Resize viewport to capture full scrollable content + --title Override the page title + --timeout Global timeout in ms (default: 60000) + --concurrency Max concurrent resource fetches (default: 6) + --json Output machine-readable JSON stats +`); + process.exit(0); + default: + console.error(`Unknown argument: ${args[i]}`); + process.exit(1); + } + } + + return opts; +} + +// --------------------------------------------------------------------------- +// Input validation +// --------------------------------------------------------------------------- +function validateOpts(opts: Opts): void { + const errors: string[] = []; + + if (!opts.url) errors.push('--url is required'); + if (!opts.output) errors.push('--output is required'); + + if (opts.url) { + try { + new URL(opts.url); + } catch { + errors.push( + `Invalid URL: "${opts.url}". Must be a valid URL (e.g., http://localhost:5173)`, + ); + } + } + + if (opts.viewport) { + const vpMatch = opts.viewport.match(/^(\d+)x(\d+)$/); + if (!vpMatch) { + errors.push( + `Invalid viewport: "${opts.viewport}". Must be WIDTHxHEIGHT (e.g., 1280x800)`, + ); + } else { + const w = Number(vpMatch[1]); + const h = Number(vpMatch[2]); + if (w < 1 || h < 1) { + errors.push('Viewport dimensions must be positive integers'); + } + if (w > 7680 || h > 4320) { + errors.push('Viewport too large: max 7680x4320'); + } + } + } + + if (isNaN(opts.wait) || opts.wait < 0) { + errors.push('--wait must be a non-negative integer'); + } + + if (isNaN(opts.timeout) || opts.timeout < 1000) { + errors.push('--timeout must be at least 1000ms'); + } + + if (isNaN(opts.concurrency) || opts.concurrency < 1 || opts.concurrency > 20) { + errors.push('--concurrency must be between 1 and 20'); + } + + if (opts.output) { + const outputDir = path.dirname(path.resolve(opts.output)); + try { + fs.mkdirSync(outputDir, { recursive: true }); + fs.accessSync(outputDir, fs.constants.W_OK); + } catch (e: unknown) { + errors.push(`Cannot write to output directory: ${(e as Error).message}`); + } + } + + if (errors.length > 0) { + console.error('❌ Validation errors:'); + errors.forEach((e) => console.error(` • ${e}`)); + process.exit(1); + } +} + +// --------------------------------------------------------------------------- +// Main snapshot logic +// --------------------------------------------------------------------------- +async function snapshot(opts: Opts): Promise { + const [, widthStr, heightStr] = opts.viewport.match(/^(\d+)x(\d+)$/)!; + const width = Number(widthStr); + const height = Number(heightStr); + + let browser: Browser | undefined; + let globalTimer: ReturnType | undefined; + + // Stats tracking + const stats: Stats = { + url: opts.url, + output: null, + sizeBytes: 0, + stylesheets: 0, + images: 0, + cssUrls: 0, + svgImages: 0, + videoPoster: 0, + favicons: 0, + scriptsRemoved: 0, + warnings: [], + durationMs: 0, + }; + const startTime = Date.now(); + + try { + // Global timeout safety net — prevents zombie browser processes + globalTimer = setTimeout(() => { + const msg = `Global timeout of ${opts.timeout}ms exceeded — aborting`; + console.error(`⏰ ${msg}`); + stats.warnings.push(msg); + if (browser) browser.close().catch(() => {}); + if (opts.json) { + stats.durationMs = Date.now() - startTime; + stats.error = msg; + console.log(JSON.stringify(stats, null, 2)); + } + process.exit(2); + }, opts.timeout); + + // ----- Launch browser ----- + console.log('🚀 Launching browser...'); + browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + `--window-size=${width},${height}`, + ], + }); + + const page = await browser.newPage(); + await page.setViewport({ width, height }); + + // Forward browser console logs to Node.js + page.on('console', (msg) => { + const type = msg.type().toString(); + if (type === 'warning' || type === 'error') { + console.log(` [Browser ${type.toUpperCase()}] ${msg.text()}`); + } + }); + + // ----- Navigate and wait for network idle ----- + console.log(`📄 Navigating to ${opts.url}...`); + try { + await page.goto(opts.url!, { + waitUntil: 'networkidle0', + timeout: Math.min(30000, opts.timeout - 5000), + }); + } catch { + const msg = 'networkidle0 timed out, falling back to networkidle2'; + console.warn(`⚠️ ${msg}...`); + stats.warnings.push(msg); + await page.goto(opts.url!, { + waitUntil: 'networkidle2', + timeout: Math.min(30000, opts.timeout - 5000), + }); + } + + // Extra wait for JS-rendered content (animations, lazy loading, etc.) + if (opts.wait > 0) { + console.log(`⏳ Waiting ${opts.wait}ms for rendering to settle...`); + await new Promise((r) => setTimeout(r, opts.wait)); + } + + // Perform click interaction if specified + if (opts.click) { + console.log(`🖱️ Clicking element "${opts.click}"...`); + try { + let element = await page.$(opts.click); + if (!element) { + // Search in child frames recursively! + for (const frame of page.frames()) { + const childElement = await frame.$(opts.click); + if (childElement) { + element = childElement; + console.log(` Found element inside child frame: ${frame.url()}`); + break; + } + } + } + + if (element) { + await element.click(); + // wait an extra 2 seconds for animation or modal loading to settle + console.log(` Click succeeded! Waiting 2000ms for click action to settle...`); + await new Promise((r) => setTimeout(r, 2000)); + } else { + throw new Error(`Selector "${opts.click}" not found in main document or child frames.`); + } + } catch (clickErr: any) { + console.error(`⚠️ Click action failed:`, clickErr); + stats.warnings.push(`Click action failed: ${clickErr.message || clickErr}`); + } + } + + // ----- Pre-processing options ----- + + // Add class to (e.g., dark mode) + if (opts.htmlClass) { + console.log(`🎨 Adding class "${opts.htmlClass}" to ...`); + await page.evaluate((cls: string) => { + document.documentElement.classList.add(...cls.split(/\s+/)); + if (cls.includes('dark')) { + document.documentElement.setAttribute('data-theme', 'dark'); + } else if (cls.includes('light')) { + document.documentElement.setAttribute('data-theme', 'light'); + } + }, opts.htmlClass); + await new Promise((r) => setTimeout(r, 500)); + } + + // Remove fixed/sticky elements + if (opts.removeFixed) { + console.log('🧹 Removing fixed/sticky positioned elements...'); + await page.evaluate(() => { + const all = document.querySelectorAll('*'); + for (const el of all) { + const style = getComputedStyle(el); + if (style.position === 'fixed' || style.position === 'sticky') { + const rect = el.getBoundingClientRect(); + if (rect.top > 100 || rect.height < 50) { + el.remove(); + } + } + } + }); + } + + // Remove custom selectors + if (opts.removeSelectors) { + console.log(`🧹 Removing custom selectors: "${opts.removeSelectors}"...`); + await page.evaluate((selectors: string) => { + const items = selectors.split(',').map((s) => s.trim()).filter(Boolean); + for (const selector of items) { + try { + document.querySelectorAll(selector).forEach((el) => el.remove()); + } catch (e) { + console.warn(`Invalid selector "${selector}":`, e); + } + } + }, opts.removeSelectors); + } + + + + // Override title + if (opts.title) { + await page.evaluate((t: string) => { + document.title = t; + }, opts.title); + } + + // ----- Inject shared browser-side helpers (deduplication) ----- + // Mock __name to prevent esbuild generated code from failing in browser + await page.evaluate(() => { + (window as any).__name = (fn: any, name: string) => fn; + }); + + await page.evaluate((concurrency: number) => { + (window as any).__snapshot = { + CONCURRENCY: concurrency, + + toDataUri: async (url: string): Promise => { + try { + const resp = await fetch(url, { + mode: 'cors', + credentials: 'same-origin', + }); + if (!resp.ok) return null; + const blob = await resp.blob(); + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = () => resolve(null); + reader.readAsDataURL(blob); + }); + } catch { + return null; + } + }, + + processInBatches: async ( + items: T[], + batchSize: number, + fn: (item: T) => Promise, + ): Promise<(R | null)[]> => { + const results: (R | null)[] = []; + for (let i = 0; i < items.length; i += batchSize) { + const batch = items.slice(i, i + batchSize); + const batchResults = await Promise.allSettled(batch.map(fn)); + results.push( + ...batchResults.map((r) => + r.status === 'fulfilled' ? r.value : null, + ), + ); + } + return results; + }, + + /** + * Robust CSS url() parser — character-by-character parsing instead of regex. + * Handles: quoted/unquoted values, escaped characters, whitespace, + * data URIs, and malformed url() tokens. + * + * Returns: Array of { url, fullMatch, start, end } + */ + extractCssUrls: (cssText: string) => { + const results: Array<{ url: string; fullMatch: string; start: number; end: number }> = []; + let i = 0; + const len = cssText.length; + + while (i < len) { + // Look for 'url(' — case insensitive + if ( + i + 3 < len && + cssText[i].toLowerCase() === 'u' && + cssText[i + 1].toLowerCase() === 'r' && + cssText[i + 2].toLowerCase() === 'l' && + cssText[i + 3] === '(' + ) { + const urlStart = i; + i += 4; // skip 'url(' + + // Skip whitespace + while ( + i < len && + (cssText[i] === ' ' || + cssText[i] === '\t' || + cssText[i] === '\n' || + cssText[i] === '\r') + ) { + i++; + } + + // Check for quote + let quote: string | null = null; + if (i < len && (cssText[i] === '"' || cssText[i] === "'")) { + quote = cssText[i]; + i++; + } + + // Read the URL value + let url = ''; + if (quote) { + // Quoted: read until matching unescaped quote + while (i < len && cssText[i] !== quote) { + if (cssText[i] === '\\' && i + 1 < len) { + i++; // skip backslash + url += cssText[i]; // include next char literally + } else { + url += cssText[i]; + } + i++; + } + if (i < len) i++; // skip closing quote + } else { + // Unquoted: stop at ) or whitespace (per CSS spec) + while ( + i < len && + cssText[i] !== ')' && + cssText[i] !== ' ' && + cssText[i] !== '\t' && + cssText[i] !== '\n' && + cssText[i] !== '\r' + ) { + url += cssText[i]; + i++; + } + } + + // Skip trailing whitespace before ')' + while ( + i < len && + (cssText[i] === ' ' || + cssText[i] === '\t' || + cssText[i] === '\n' || + cssText[i] === '\r') + ) { + i++; + } + + if (i < len && cssText[i] === ')') { + const fullMatch = cssText.substring(urlStart, i + 1); + results.push({ + url: url.trim(), + fullMatch, + start: urlStart, + end: i + 1, + }); + i++; + } else { + // Malformed url() — skip past 'url(' and try again + i = urlStart + 1; + } + } else { + i++; + } + } + + return results; + }, + + /** + * Replace CSS url() references using pre-computed positions. + * Replaces from end-to-start to preserve earlier indices. + */ + replaceCssUrls: ( + cssText: string, + replacements: Array<{ start: number; end: number; dataUri: string }>, + ): string => { + const sorted = [...replacements].sort((a, b) => b.start - a.start); + for (const r of sorted) { + cssText = + cssText.substring(0, r.start) + + "url('" + + r.dataUri + + "')" + + cssText.substring(r.end); + } + return cssText; + }, + }; + }, opts.concurrency); + + // ----------------------------------------------------------------------- + // -1. Inline local iframes (e.g., companion-app test iframe) + // ----------------------------------------------------------------------- + const iframesCount = await page.evaluate(() => document.querySelectorAll('iframe').length); + if (iframesCount > 0) { + console.log(`🔍 Found ${iframesCount} iframe(s) in the main page. Extracting content natively...`); + + // First, recursively inline all same-origin and srcDoc iframes browser-side + console.log('🔍 Inlining same-origin and srcDoc iframes recursively...'); + await page.evaluate(() => { + const inlineSameOriginIframes = (root: Document | HTMLElement) => { + const iframes = Array.from(root.querySelectorAll('iframe')); + for (const iframe of iframes) { + try { + const doc = iframe.contentDocument || iframe.contentWindow?.document; + if (doc && doc.body) { + // Recursively inline same-origin iframes inside this child frame first + inlineSameOriginIframes(doc); + + const bodyHtml = doc.body.innerHTML; + + const styles: string[] = []; + doc.querySelectorAll('style').forEach(s => styles.push(s.outerHTML)); + doc.querySelectorAll('link[rel="stylesheet"]').forEach(l => styles.push((l as HTMLLinkElement).outerHTML)); + + styles.forEach(styleHtml => { + const temp = document.createElement('div'); + temp.innerHTML = styleHtml; + document.head.appendChild(temp.firstChild!); + }); + + const wrapper = document.createElement('div'); + wrapper.className = 'ac-iframe-inlined-wrapper'; + + // Apply child body's classes and attributes to same-origin wrapper + for (const attr of Array.from(doc.body.attributes)) { + if (attr.name === 'class') { + wrapper.classList.add(...attr.value.split(/\s+/).filter(Boolean)); + } else if (attr.name !== 'style') { + wrapper.setAttribute(attr.name, attr.value); + } + } + + wrapper.style.position = 'absolute'; + wrapper.style.top = '0'; + wrapper.style.left = '0'; + wrapper.style.width = '100%'; + wrapper.style.height = '100%'; + wrapper.style.overflow = 'hidden'; + wrapper.innerHTML = bodyHtml; + + iframe.parentNode!.replaceChild(wrapper, iframe); + } + } catch (e) { + // Ignore cross-origin iframes; the Puppeteer frame loop will process them + } + } + }; + inlineSameOriginIframes(document); + }); + + const childFrames = page.frames() + .filter(f => f !== page.mainFrame()) + .map(f => { + let depth = 0; + let p = f.parentFrame(); + while (p) { + depth++; + p = p.parentFrame(); + } + return { frame: f, depth }; + }) + .sort((a, b) => b.depth - a.depth); + + for (const { frame } of childFrames) { + try { + const frameUrl = frame.url(); + const cleanUrl = frameUrl.split('?')[0].split('#')[0]; + console.log(`📦 Extracting frame content from: ${cleanUrl} (depth: ${frame.parentFrame() ? 'nested' : 'root'})`); + + // Inject __name mock to prevent esbuild helper ReferenceError in child frame + await frame.evaluate(() => { + (window as any).__name = (fn: any) => fn; + }); + + // Resolve all relative assets inside the frame to absolute URLs relative to the frame's URL + await frame.evaluate((base) => { + const resolveAttr = (el: Element, attr: string) => { + const val = el.getAttribute(attr); + if (val && !val.startsWith('data:') && !val.startsWith('http:') && !val.startsWith('https:') && !val.startsWith('//')) { + try { + const abs = new URL(val, base).href; + el.setAttribute(attr, abs); + } catch (e) { } + } + }; + document.querySelectorAll('img[src]').forEach(img => resolveAttr(img, 'src')); + document.querySelectorAll('img[srcset]').forEach(img => resolveAttr(img, 'srcset')); + document.querySelectorAll('source[srcset]').forEach(src => resolveAttr(src, 'srcset')); + document.querySelectorAll('link[rel="stylesheet"]').forEach(link => resolveAttr(link, 'href')); + + // Resolve relative url() references in inline +
+ +
+
+ +
+
+ +
+``` + +**Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. + +The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no preview CSS, omit the ` +
+ {/* variant 1 */} +
+
+ {/* variant 2 */} +
+``` + +The wrap script already gives you a single-rooted JSX wrapper: a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. + +### 7. Parameters (composition-sized, 0–4 per variant) + +Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. + +**What “optional” does not mean.** Parameters are not nice-to-have decoration on large work. The word meant “omit controls that are redundant or cosmetic,” not “default to zero because three variants were enough work.” + +**When to add.** As soon as the variant’s scoped CSS has a meaningful continuous or stepped axis: density, color amount, type scale, motion intensity, column weight, and so on. If you can imagine the user muttering “a bit tighter” or “a touch more accent” **without** wanting a full regeneration, wire that axis. **Not** micro-margins or one-off nudges; those are not parameters. + +**Freeform (`action` is `impeccable`) bias.** You did not load a sub-command reference, so you must **choose** signature axes yourself. Match the budget table: for a hero or large composition, that means **2–3 axes per variant**, not 1. Prefer knobs that sit on the dimensions where your three variants actually differ (if density varies, expose it as a `steps` knob; if color commitment varies, expose it as a `range`). A hero that ships with **0** params is almost always a mistake, not a judgment call. A hero with exactly **1** param is underweight unless the design is genuinely a fixed-point comparison. Start from the budget table, not from zero. + +**Budget scales with the element's visual weight, not token budget.** Knobs need real estate to read as tunable; three sliders on a single control are noise. + +- **Leaf / tiny**: a single button, icon, input, bare heading, solitary paragraph: **0 params.** +- **Small composition**: labeled input, simple card, short callout (≤ ~5 visual children): **0–1** params when one dominant axis is obvious; otherwise **0.** +- **Medium composition**: section component, nav cluster, dense card, short feature block (6–15 visual children): **target 2**; **1** is acceptable if the block is simple; **0** only when variants are truly fixed points. +- **Large composition**: hero section, full page region, spread layout, strong internal structure (16+ visual children or multiple sub-sections): **target 2–3**; **up to 4** when several independent axes (e.g. structure `steps` + `density` + one accent) are all authored in scoped CSS. + +**When in doubt, ask whether a dial exists before defaulting to zero.** The user can always request more variants, but the point of live mode is instant tuning without another Go. Crowding the panel is bad; **under-shipping** knobs on a dense composition is the more common failure for freeform. Count by **visual** children, not DOM depth; a shallow-but-wide hero is still large. + +**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. + +**How to declare.** Put a JSON manifest on the variant wrapper: + +```html +
+ ...variant content... +
+``` + +**Three kinds:** + +- `range`: smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`. +- `steps`: segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`. +- `toggle`: on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`. + +**Signature params per action.** For named sub-commands, read that action’s `reference/.md` for one or two **MUST** params (e.g. `layout` → `density`). Those are non-negotiable when the design can express them. **Freeform has no file-level MUST**; the **Freeform (`impeccable`) bias** in this section is the stand-in. If the user’s action is both stylized and sub-command (e.g. `colorize`), the sub-command’s MUST list takes precedence for its axes; still respect the **Hard cap** and add no redundant duplicate knobs. + +**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later. + +**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment: + +```html + +``` + +The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default. + +### 8. Signal done + +```bash +node .agents/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH +``` + +`RELATIVE_PATH` is relative to project root (`public/index.html`, `src/App.tsx`, etc.); the browser fetches source directly if the dev server lacks HMR. + +Then run `live-poll.mjs` again immediately. + +### Aborting an in-flight session + +If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: + +```bash +node .agents/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" +``` + +Don't run `live-accept --discard` for this; that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. + +## Handle fallback + +When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. + +The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself. + +### Step 1: Identify where the element actually lives + +Use the error payload: + +- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"`: the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element. +- `element_not_found`: the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it. +- `file_is_generated` with `file: "..."`: user pointed at a generated file explicitly. Same resolution as `element_not_in_source`. + +Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template. + +### Step 2: Show three variants in the DOM for preview + +The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something: + +1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces; `
`. +2. Insert your three variant divs inside it, same shape as the deterministic path. +3. Signal done with `--reply EVENT_ID done --file `. The browser's no-HMR fallback will fetch and inject. + +This served-file edit is **temporary**: next regen wipes it, and that's fine. The real work happens on accept. + +### Step 3: On accept, write to true source + +When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files; see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1: + +- Structural change → edit the template / component source. +- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `' : '')); + if (paramValues && Object.keys(paramValues).length > 0) { + // Preserve the user's knob positions for the carbonize-cleanup agent + // to bake into the final CSS when it collapses scoped rules. + replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close); + } + replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); + } + + // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the + // carbonize CSS block working visually by re-wrapping the accepted content + // in a data-impeccable-variant="N" div with `display: contents` (so layout + // isn't affected). The carbonize agent strips this attribute + wrapper when + // it moves the CSS to a proper stylesheet. + // + // Style attribute syntax has to follow the host file's flavor — JSX files + // need the object form, otherwise React 19 throws "Failed to set indexed + // property [0] on CSSStyleDeclaration" while parsing the string char-by-char. + if (cssContent) { + const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; + replacement.push(indent + '
'); + replacement.push(...restored); + replacement.push(indent + '
'); + } else { + replacement.push(...restored); + } + + const newLines = [ + ...lines.slice(0, replaceRange.start), + ...replacement, + ...lines.slice(replaceRange.end + 1), + ]; + fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); + + return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; +} + +// --------------------------------------------------------------------------- +// Parsing helpers +// --------------------------------------------------------------------------- + +/** + * Find the start/end marker lines for a session. + * Returns { start, end } (0-indexed line numbers) or null. + */ +function findMarkerBlock(id, lines) { + let start = -1; + let end = -1; + const startPattern = 'impeccable-variants-start ' + id; + const endPattern = 'impeccable-variants-end ' + id; + + for (let i = 0; i < lines.length; i++) { + if (start === -1 && lines[i].includes(startPattern)) start = i; + if (lines[i].includes(endPattern)) { end = i; break; } + } + + return (start !== -1 && end !== -1) ? { start, end, id } : null; +} + +/** + * Compute the line range to REPLACE (vs. just the marker range to extract + * from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE + * the `
` outer wrapper so the picked + * element's JSX slot keeps a single child — a Fragment `<>` would have + * solved the multi-sibling case but failed inside `asChild` / cloneElement + * parents with "Invalid prop supplied to React.Fragment". + * + * That means the marker block is enclosed by the wrapper `
` opener + * (with `data-impeccable-variants="ID"`) and its matching `
`. We + * walk back to the opener and forward to the closer so accept/discard + * remove the entire scaffold, not just the inner markers. + * + * Marker lines themselves stay where they were so extractOriginal / + * extractVariant / extractCss continue to walk the same range. + */ +function expandReplaceRange(block, lines, isJsx) { + if (!isJsx) return { start: block.start, end: block.end }; + + let { start, end } = block; + + // Walk back for the wrapper `
= 0; i--) { + if (isVariantEndMarkerLine(lines[i], block.id)) break; + if (hasVariantWrapperAttr(lines[i], block.id)) { + let opener = i; + while (opener > 0 && !/` by div-depth tracking from the + // wrapper opener. Operate on JOINED text instead of per-line: a + // multi-line self-closing JSX `` would + // fool per-line regex tracking (the `` line never matches selfCloseRe since it needs `` orphaned after accept/discard. Single regex with + // `[^>]*?` (which spans newlines in JS) handles either form correctly. + const joined = lines.slice(start).join('\n'); + // Match either `
` (self-close, group 1 is `/`), `
` + // (open, group 1 is empty), or `
`. + const tagRe = /]*?(\/?)>|<\/div\s*>/g; + let depth = 0; + let m; + while ((m = tagRe.exec(joined)) !== null) { + const isClose = m[0].startsWith('= end) { + end = candidateEnd; + break; + } + } + } + + return { start, end }; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function isVariantEndMarkerLine(line, id) { + return new RegExp('impeccable-variants-end\\s+' + escapeRegExp(id) + '(?:\\s|--|\\*/|$)').test(line); +} + +function hasVariantWrapperAttr(line, id) { + const escaped = escapeRegExp(id); + return new RegExp(`data-impeccable-variants\\s*=\\s*(?:"${escaped}"|'${escaped}'|\\{["']${escaped}["']\\})`).test(line); +} + +/** + * Join wrapper lines into a single string with `` to close on) + * - Same-line `` blocks + * - Multi-line `` blocks + */ +function stripStyleAndJoin(lines, block) { + const out = []; + let inStyle = false; + for (let i = block.start; i <= block.end; i++) { + let line = lines[i]; + + if (!inStyle) { + // Strip any complete . + const closeIdx = line.search(/<\/style\s*>/); + if (closeIdx !== -1) { + inStyle = false; + out.push(line.slice(closeIdx).replace(/<\/style\s*>/, '')); + } + // else: skip line entirely + } + } + return out.join('\n'); +} + +/** + * Find the inner content of `` inside `text`, + * handling nested same-tag elements via depth counting. `attrMatch` is a + * regex source fragment that must appear inside the opener tag. + * Returns the inner string (may be empty), or null if not found. + */ +function extractInnerByAttr(text, attrMatch) { + const openerRe = new RegExp('<([A-Za-z][A-Za-z0-9]*)\\b[^>]*' + attrMatch + '[^>]*>'); + const openMatch = text.match(openerRe); + if (!openMatch) return null; + + const tagName = openMatch[1]; + const innerStart = openMatch.index + openMatch[0].length; + + // Match any opener or closer of this tag name after innerStart. + // (Does not match self-closing , which doesn't contribute to depth.) + const tagRe = new RegExp('<(?:/)?' + tagName + '\\b[^>]*>', 'g'); + tagRe.lastIndex = innerStart; + + let depth = 1; + let m; + while ((m = tagRe.exec(text))) { + const isClose = m[0].startsWith('$/.test(m[0]); + if (isClose) { + depth--; + if (depth === 0) return text.slice(innerStart, m.index); + } else if (!isSelfClose) { + depth++; + } + } + return null; +} + +/** + * Extract the original element content from within the variant wrapper. + * Returns an array of lines. + */ +function extractOriginal(lines, block) { + const text = stripStyleAndJoin(lines, block); + const inner = extractInnerByAttr(text, 'data-impeccable-variant="original"'); + if (inner === null) return []; + return inner.split('\n'); +} + +/** + * Extract a specific variant's inner content (stripping the wrapper div). + * Returns an array of lines, or null if not found. + */ +function extractVariant(lines, block, variantNum) { + const text = stripStyleAndJoin(lines, block); + const inner = extractInnerByAttr(text, 'data-impeccable-variant="' + variantNum + '"'); + if (inner === null) return null; + const result = inner.split('\n'); + // Collapse a lone empty leading/trailing line (common after string splice). + while (result.length > 1 && result[0].trim() === '') result.shift(); + while (result.length > 1 && result[result.length - 1].trim() === '') result.pop(); + return result.length > 0 ? result : null; +} + +/** + * Extract the colocated ` — return the inner content. + * 3. Multi-line: `` on a later line — return + * the lines between them. + */ +function extractCss(lines, block, id) { + const styleAttr = 'data-impeccable-css="' + id + '"'; + let inStyle = false; + const content = []; + + for (let i = block.start; i <= block.end; i++) { + const line = lines[i]; + + if (!inStyle && line.includes(styleAttr)) { + // Self-closing: nothing to carbonize. + if (/]*\/\s*>/.test(line)) return null; + // Same-line open + close: extract inner text. + const sameLine = line.match(/]*>([\s\S]*?)<\/style\s*>/); + if (sameLine) { + const inner = stripJsxTemplateWrap(sameLine[1]); + return inner.length > 0 ? inner.split('\n') : null; + } + inStyle = true; + continue; // skip the anywhere on the line — JSX template-literal closes + // (`}`) put the close mid-line, and we don't want to absorb the + // template-literal punctuation as CSS content. + const closeIdx = line.indexOf(''); + if (closeIdx !== -1) break; + content.push(line); + } + } + + if (content.length === 0) return null; + return stripJsxTemplateLines(content); +} + +/** + * Strip a JSX template-literal wrap (`{` … `}`) from CSS extracted out of a + * ` close.', + 'Prefix every preview selector with the matching [data-impeccable-variant="N"] selector.', + 'Keep selectors anchored to the generated variant wrapper; do not rely on component CSS scoping for preview rules.', + ], + forbidden: [ + 'Do not use @scope for this styleMode.', + 'Do not wrap style content in a JSX/TSX template literal ({` ... `}); that syntax is for .tsx/.jsx only.', + 'Do not put { immediately after the style opening tag; Astro parses { as expression syntax.', + ], + }; + } + return { + mode: styleMode.mode, + styleTag: styleMode.styleTag, + strategy: 'scope-rule', + rulePattern: '@scope ([data-impeccable-variant="N"]) { :scope > .variant-class { ... } }', + selectorExamples: variantNumbers.map((n) => `@scope ([data-impeccable-variant="${n}"]) { :scope > .variant-class { ... } }`), + requirements: [ + 'Use @scope blocks keyed to each [data-impeccable-variant="N"] wrapper.', + 'Inside each @scope block, make :scope rules step into the replacement element with a descendant combinator.', + 'Use the styleTag exactly; do not add framework-specific style attributes unless this object says to.', + ], + forbidden: [ + 'Do not use global [data-impeccable-variant="N"] selector prefixes for this styleMode.', + 'Do not add is:inline to the style tag for this styleMode.', + ], + }; +} + +/** + * Search project files for the query string (class name, ID, etc.) + * Returns the first matching file path, or null. + */ +function findFileWithQuery(query, cwd, genOpts = {}) { + const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; + const seen = new Set(); + + for (const dir of searchDirs) { + const absDir = path.join(cwd, dir); + if (!fs.existsSync(absDir)) continue; + const result = searchDir(absDir, query, seen, 0, genOpts); + if (result) return result; + } + return null; +} + +function searchDir(dir, query, seen, depth, genOpts) { + if (depth > 5) return null; // don't go too deep + const realDir = fs.realpathSync(dir); + if (seen.has(realDir)) return null; + seen.add(realDir); + + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return null; } + + // Check files first + for (const entry of entries) { + if (!entry.isFile()) continue; + const ext = path.extname(entry.name).toLowerCase(); + if (!EXTENSIONS.includes(ext)) continue; + + const filePath = path.join(dir, entry.name); + if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue; + try { + const content = fs.readFileSync(filePath, 'utf-8'); + if (content.includes(query)) return filePath; + } catch { /* skip unreadable files */ } + } + + // Then recurse into directories. Always skip node_modules and .git (never + // project content). dist/build/out are left to the isGeneratedFile guard so + // the includeGenerated second-pass can still find the element there and + // report `generatedMatch`. + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name === 'node_modules' || entry.name === '.git') continue; + const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); + if (result) return result; + } + + return null; +} + +/** + * Regex that matches a tag opener on a line. Allows the tag name to be + * followed by whitespace, `>`, `/`, or end-of-line so that multi-line JSX + * openers (e.g. ``) are recognised. + */ +const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/; + +/** + * Find the element's start and end line in the file. + * + * `query` is a class name, attribute fragment (`class="..."`, `className="..."`, + * `id="..."`), or a raw text snippet. Because a query can appear on a + * continuation line of a multi-line tag (e.g. the `className="..."` row of a + * `` JSX tag), we walk backward from the match + * line to find the actual tag opener. When `tag` is provided, opener candidates + * must match that tag name. + */ +/** + * Return the smallest leading-whitespace count across a set of lines, + * ignoring blank lines (whose indent isn't load-bearing). Used to compute + * the common base indent of a multi-line picked element so reindenting + * under the wrapper preserves the relative depth between lines. + */ +function minLeadingSpaces(lines) { + let min = Infinity; + for (const l of lines) { + if (l.trim() === '') continue; + const m = l.match(/^(\s*)/); + if (m && m[1].length < min) min = m[1].length; + } + return min === Infinity ? 0 : min; +} + +function findElement(lines, query, tag = null) { + // Iterate all matches — the first substring hit isn't always the right one. + for (let i = 0; i < lines.length; i++) { + if (!lines[i].includes(query)) continue; + + const stripped = lines[i].trim(); + if (stripped.startsWith(''; + +/** + * Walk up from startDir to find a project root. + */ +function findProjectRoot(startDir = process.cwd()) { + let dir = resolve(startDir); + while (dir !== '/') { + if ( + existsSync(join(dir, 'package.json')) || + existsSync(join(dir, '.git')) || + existsSync(join(dir, 'skills-lock.json')) + ) { + return dir; + } + const parent = resolve(dir, '..'); + if (parent === dir) break; + dir = parent; + } + return resolve(startDir); +} + +/** + * Find harness skill directories that have an impeccable skill installed. + */ +function findHarnessDirs(projectRoot) { + const dirs = []; + for (const harness of HARNESS_DIRS) { + const skillsDir = join(projectRoot, harness, 'skills'); + // Only pin in harness dirs that already have impeccable installed + const impeccableDir = join(skillsDir, 'impeccable'); + if (existsSync(impeccableDir) || existsSync(join(skillsDir, 'i-impeccable'))) { + dirs.push(skillsDir); + } + } + return dirs; +} + +/** + * Load command metadata (descriptions for pinned skills). + */ +function loadCommandMetadata() { + const metadataPath = join(__dirname, 'command-metadata.json'); + if (existsSync(metadataPath)) { + return JSON.parse(readFileSync(metadataPath, 'utf-8')); + } + return {}; +} + +/** + * Generate a pinned skill's SKILL.md content. + */ +function generatePinnedSkill(command, metadata) { + const desc = metadata[command]?.description || `Shortcut for /impeccable ${command}.`; + const hint = metadata[command]?.argumentHint || '[target]'; + + return `--- +name: ${command} +description: "${desc}" +argument-hint: "${hint}" +user-invocable: true +--- + +${PIN_MARKER} + +This is a pinned shortcut for \`{{command_prefix}}impeccable ${command}\`. + +Invoke {{command_prefix}}impeccable ${command}, passing along any arguments provided here, and follow its instructions. +`; +} + +/** + * Pin a command: create shortcut skill in all harness dirs. + */ +function pin(command, projectRoot) { + const metadata = loadCommandMetadata(); + const harnessDirs = findHarnessDirs(projectRoot); + + if (harnessDirs.length === 0) { + console.log('No harness directories with impeccable installed found.'); + return false; + } + + const content = generatePinnedSkill(command, metadata); + let created = 0; + + for (const skillsDir of harnessDirs) { + // Check if skill already exists (and isn't a pin) + const skillDir = join(skillsDir, command); + if (existsSync(skillDir)) { + const existingMd = join(skillDir, 'SKILL.md'); + if (existsSync(existingMd)) { + const existing = readFileSync(existingMd, 'utf-8'); + if (!existing.includes(PIN_MARKER)) { + console.log(` SKIP: ${skillDir} (non-pinned skill already exists)`); + continue; + } + } + } + + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), content, 'utf-8'); + console.log(` + ${skillDir}`); + created++; + } + + if (created > 0) { + console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`); + console.log(`You can now use /${command} directly.`); + } + + return created > 0; +} + +/** + * Unpin a command: remove shortcut skill from all harness dirs. + */ +function unpin(command, projectRoot) { + const harnessDirs = findHarnessDirs(projectRoot); + let removed = 0; + + for (const skillsDir of harnessDirs) { + const skillDir = join(skillsDir, command); + if (!existsSync(skillDir)) continue; + + const skillMd = join(skillDir, 'SKILL.md'); + if (!existsSync(skillMd)) continue; + + // Safety: only remove if it's a pinned skill + const content = readFileSync(skillMd, 'utf-8'); + if (!content.includes(PIN_MARKER)) { + console.log(` SKIP: ${skillDir} (not a pinned skill)`); + continue; + } + + rmSync(skillDir, { recursive: true, force: true }); + console.log(` - ${skillDir}`); + removed++; + } + + if (removed > 0) { + console.log(`\nUnpinned '${command}' from ${removed} location(s).`); + console.log(`Use /impeccable ${command} to access it.`); + } else { + console.log(`No pinned '${command}' shortcut found.`); + } + + return removed > 0; +} + +// --- CLI --- +const [,, action, command] = process.argv; + +if (!action || !command) { + console.log('Usage: node pin.mjs '); + console.log(`\nAvailable commands: ${VALID_COMMANDS.join(', ')}`); + process.exit(1); +} + +if (action !== 'pin' && action !== 'unpin') { + console.error(`Unknown action: ${action}. Use 'pin' or 'unpin'.`); + process.exit(1); +} + +if (!VALID_COMMANDS.includes(command)) { + console.error(`Unknown command: ${command}`); + console.error(`Available commands: ${VALID_COMMANDS.join(', ')}`); + process.exit(1); +} + +const root = findProjectRoot(); + +if (action === 'pin') { + pin(command, root); +} else { + unpin(command, root); +} diff --git a/rules/requirements-alignment-guide.md b/rules/requirements-alignment-guide.md new file mode 100644 index 0000000..13f6617 --- /dev/null +++ b/rules/requirements-alignment-guide.md @@ -0,0 +1,121 @@ +# 需求与设计对齐指南 + +用于新建或明显更新原型、主题、项目文档等资源时,先收敛产品需求,再确认设计方案。对齐贯穿全过程:读取资料、生成规格、计划实施和验收时,只要发现会改变方向、范围、成本或验收标准的问题,都应回到相应阶段继续确认。 + +核心流程: + +```text +读取上下文 -> 产品需求对齐 -> 产品需求确认 -> 设计方案对齐(V2 基底已锁定)-> 设计决策确认 -> 规格/计划 -> 实施验证 +``` + +## 何时触发 + +- 新建原型页面、主题或项目文档。 +- 明显重构信息架构、核心交互、页面流程或视觉方向。 +- 用户需求模糊,例如只说“生成一个健身 APP”。 +- 存在多种合理功能范围、布局方式、交互路径、内容组织或视觉方案。 +- 用户明确要求先看方向、先出方案、先写规格或先出计划。 +- 资料、主题、现有原型或资源目录之间有冲突,且会改变产出方向。 + +局部文案、样式、素材替换、明确 bug 修复等不改变产品范围、信息架构和视觉骨架的任务,可以跳过正式对齐;但仍要记录采用的假设,并在发现关键缺口时回到对齐流程。 + +## 上下文读取 + +提问前先读取可获得的信息,不把项目里能找到的问题抛给用户。 + +优先级: + +1. 用户当前消息、附件、截图、链接和已给出的约束。 +2. 当前目录最近的 `AGENTS.md`、`README.md` 和相关 `rules/`。 +3. 关联原型:目录结构、入口文件、必要样式和已有交互。 +4. 资源目录:只做目录级扫描,了解已有原型、主题、文档和资产。 +5. 相关文件:只读取会影响本次判断的文件,不批量展开无关文件。 + +重点看: + +- `src/prototypes/`:是否已有同类原型、参考页或可复用页面结构。 +- `src/themes/`:是否已有相近主题或设计系统。 +- `src/resources/`:是否已有业务说明、字段、流程资料或素材。 +- 原型内 `assets/`、`canvas-assets/`:是否有用户提供或历史沉淀的素材。 + +## 产品需求对齐 + +产品需求阶段负责回答“做什么”,方向应以收敛为主。通常需要明确: + +- 目标用户和核心任务。 +- 本次范围、功能清单和不做什么。 +- 页面或资源的核心内容、数据来源和素材来源。 +- 关键状态、核心路径和必要交互。 +- 用户最终如何判断结果可用。 + +只问会影响范围、成本或验收的问题。能从上下文推断的内容直接记为假设继续;如果缺失信息会导致不同功能范围、不同页面结构或不同验收标准,必须先问。 + +## 设计方案对齐 + +设计方案阶段负责回答“怎么表达”。 + +### 设计基底(本仓库已锁定,禁止再选) + +本仓库 **全部** `src/prototypes/**` 的设计基底 **固定为** OneOS V2 设计规范,不得再向用户罗列或比选 `src/themes/*` 或其他主题: + +| 项 | 路径 | +|----|------| +| 总览 | `src/resources/design-system/DESIGN.md` | +| Token / CSS | `src/resources/design-system/tokens.json`、`oneos-ds-tokens.css` | +| 分章 | `src/resources/design-system/chapters/` | +| Agent 强制规则 | `.cursor/rules/oneos-v2-design-system.mdc`(`alwaysApply: true`) | + +`src/themes/` 仅用于主题演示与素材库,**不是**业务原型的设计基底候选源。用户零散提出的颜色、字体、布局、动效等,一律视为在 V2 规范上的局部调整,不另起视觉系统。 + +### 本阶段应对齐的内容 + +进入明显 UI 改版或新建页前,对齐布局、交互与内容呈现(**不问设计基底**)。新建页面、大面积 UI 改版或模糊需求,设计阶段至少覆盖 5 个设计问题或设计决策变量;局部微调、明确 bug 修复或用户已给出完整设计约束时不受此下限限制。 + +常见设计问题包括: + +- 首屏目标:主操作、信息概览或引导转化。 +- 信息层级:哪些内容必须突出,哪些可以折叠或延后。 +- 布局模式:V2 列表 / 看板 / 主从、详情全页、向导等(在规范模板内选型)。 +- 交互路径:高频用户效率优先,还是首次用户理解优先。 +- 数据呈现:真实数据、文档推断、临时示例数据或空状态优先。 +- 视觉语气:严格继承 V2(默认);仅用户明确要求时在规范内加强某种倾向。 + +落地时必须引入 V2 CSS Variables / tokens 与 `OneOsAppShell`(适用 PC Web 时);缺少 token 时贴近 V2 视觉语言,并在方案或交付说明中写明假设。 + +## 可视化对齐 + +对齐内容保持简洁,不做过度扩写;优先用轻量文本图帮助用户确认关键结构。 + +- 新建页面、大面积 UI 改版或布局不明确时,必须先用 ASCII Wireframe 对齐页面布局,再进入实现。 +- ASCII Wireframe 用于表达区域、层级、主次关系和关键操作位置,不替代最终视觉设计。 +- 流程、结构、关系和图表类内容,可按需使用 ASCII Diagram 辅助沟通;只有会帮助用户更快判断方向时才使用。 + +## 提问规则 + +- 先读上下文,能推断就不问。 +- 默认一次只问一个决策点;同一主题下强相关的 2-3 个参数可以合并为一问。 +- 尽量给出 2-3 个互斥选项,并标出推荐答案。 +- 推荐答案要说明取舍,不只表达偏好。 +- 不为了补齐形式追问已能从上下文判断的问题。 +- 用户回答后继续收敛;如果出现新分支,再追加提问。 + +## 确认与记录 + +进入实现前必须让用户确认产品需求和设计方案。确认产物可以是中文规格文档、执行计划或方案摘要,但至少包含: + +- 产品需求:目标用户、核心任务、范围、功能清单、内容/数据来源、验收重点。 +- 设计方案:设计基底固定为 V2(`src/resources/design-system/DESIGN.md`)、整体设计方向、关键设计决策、设计假设。 +- 实施边界:本轮会做什么,不会做什么。 + +确认过的方案或规格需要归档为当时决策快照;未确认的不归档。 + +- 保存位置:原型相关内容保存到 `src/prototypes//.spec/`。 +- 文件命名:文件名必须包含日期,建议使用 `YYYY-MM-DD-.md`。 +- 归档内容:只记录已确认的需求问题、设计问题、用户选择和最终设计决策。 +- 归档性质:只表示当下决策,不要求跟随后续实现继续同步变化。 + +触发过对齐时,本轮交付说明或确认产物中只需补充: + +- 问题。 +- 用户选择。 +- 最终设计决策。 diff --git a/rules/resource-management-guide.md b/rules/resource-management-guide.md new file mode 100644 index 0000000..a5b07c3 --- /dev/null +++ b/rules/resource-management-guide.md @@ -0,0 +1,49 @@ +# 资源指南 + +`src/resources/` 用于存放项目资料、需求说明、文档、图表和原型讨论中需要长期保留的上下文,方便后续生成、修改和复盘原型时读取。新建项目文档时,默认保存到这里。 + +常见内容包括: + +- 文档:Markdown、HTML 文档,如需求说明、页面说明、调研记录、会议纪要 +- 图表:Draw.io 可编辑图表,如 `.drawio`、`.drawio.svg` +- 数据样例:JSON、CSV、TSV、YAML、TXT、表格导出文件 +- 设计或业务附件:图片、PDF、Office 文档、压缩包等 + +## 目录边界 + +- `src/resources/` 存放长期项目资料、文档、图表、数据样例和业务附件。 +- `src/resources/templates/` 存放可复用文档模板;模板也是文档资源的一种。 +- 原型页面专属素材放在对应原型目录内,例如 `src/prototypes//assets/`。 +- 画布截图、原型占位位图、画布生成图等放在对应原型的 `canvas-assets/`,不要搬到资源目录。 +- 主题素材放在对应主题目录内,例如 `src/themes//assets/`。 + +图片、截图、参考图等素材只在需要长期保留为项目资料时放入 `src/resources/`。 + +## 资源链接 + +资源通常保留两种链接: + +- 只读链接:用于预览、嵌入、下载或外部读取。 +- 编辑链接:Make 管理端地址,用于打开资源详情页,带顶部工具栏和系统编辑能力。 + +编辑链接统一使用 Make 管理端 deep link: + +```text +/?projectId=&doc= +``` + +其中 `resource-path` 是相对 `src/resources/` 的路径,例如 `templates/prd-template.md` 或 `flows/order-status.drawio`。 + +除 Markdown 外,所有资源的只读链接统一使用文档资源文件地址: + +```text +/api/docs/?projectId= +``` + +Markdown 文档 `.md` 的只读预览使用文档预览页: + +```text +/spec-template.html?url= +``` + +其中 `doc-api-url` 通常是 `/api/docs/?projectId=`。 diff --git a/rules/theme-guide.md b/rules/theme-guide.md new file mode 100644 index 0000000..9939606 --- /dev/null +++ b/rules/theme-guide.md @@ -0,0 +1,162 @@ +# 主题创建与验收指南 + +本文档约束 `make-client` 中主题资源的创建、更新、派生与验收。主题只面向当前标准结构。 + +## 核心原则 + +- `DESIGN.md` 是主题事实源:品牌定位、设计原则、色彩、字体、圆角、间距、边框、阴影、组件规则和禁用做法,均优先按它判断。 +- `theme.json` 是运行时与管理端消费的结构化摘要;`assets/tokens.json` 是轻量 token 快照;`style.css` 是演示页可见样式。三者必须与 `DESIGN.md` 保持一致。 +- 用户当前消息或附件的优先级高于已有文件;若用户要求与 `DESIGN.md` 冲突,先更新 `DESIGN.md`,再同步派生文件。 +- 不根据截图、元数据或自动推断结果覆盖明确写在 `DESIGN.md` 中的规则;只能在 `DESIGN.md` 缺失信息时补充合理假设,并在文档或交付说明中写清楚。 +- 主题代码、CSS 和 `theme.json` 中的本地资源引用必须使用主题内相对路径,禁止根路径、本机绝对路径或 `../` 逃逸到其他目录。 +- 主题演示页不得引入与该主题无关的 UI 库,避免污染视觉表达。 + +## 标准参考主题 + +当前标准参考主题是 `src/themes/linear/`。新建或重做主题时,参考它的目录结构、`theme.json` 字段组织、`index.tsx` 接入方式和预览资源引用方式。 + +参考范围: + +- `index.tsx`:引入 `./style.css`、读取 `./theme.json`、将 `display` 映射为 `DesignMdBatchShowcase` 配置,并静态 import 本地预览资源。 +- `theme.json`:包含 `schemaVersion`、`source`、`identity`、`tags`、`assets`、`tokens`、`previewImages`、`display`。 +- `style.css`:以 `@import "tailwindcss";` 开头,在 `.dmb-page` 中写入 `--dmb-*` CSS Variables。 +- `assets/`:至少包含 `tokens.json` 和一个稳定预览图,例如 `official-homepage.webp`、`cover.webp` 或 `source-preview.webp`。 + +不要复制参考主题的品牌内容、视觉风格、文案或临时生成注释。新主题必须用自己的 `DESIGN.md` 派生真实 token、展示字段和预览资源。 + +## 推荐来源与导入路径 + +用户查找和导入主题时,优先走这两类来源: + +- `getdesign.md`:主流 Design.md 主题的优先来源,适合先找品牌主线和标准主题。 +- `styles.refero.design`:补缺优先来源,适合找行业、场景、字体、颜色和 token 更丰富的主题。 + +当前只推荐这两类 active source;旧来源不作为默认导入入口。 + +推荐的导入路径按这个顺序走: + +```bash +node scripts/collect-design-md-batch.mjs +node scripts/generate-design-md-theme-pages.mjs +node scripts/review-design-md-theme-pages.mjs +``` + +对应的本地产物和主题落点分别是: + +- `.local/design-md-batch/manifest.json` +- `client/src/themes//` + +如果用户已经提供了 Design.md 线索、品牌名或详情页链接,就先按上面两类来源定位,再进入采集、生成和复查。 + +## 网页采集分流 + +当用户提供官网、产品页、落地页或其他网页地址,并要求“参考这个网站”“提取主题”“生成主题”“分析设计风格”时,先读取 `rules/theme-source-capture-guide.md`,完成必要的截图、token、响应式和资源证据采集,再回到本文档生成标准主题。 + +采集只是补充来源证据,不替代主题工作流。若用户已经提供明确的 `DESIGN.md`、设计规范、品牌手册或当前主题事实源充分,则不要默认重新采集网页;只有证据不足、用户指定 URL、预览图缺失或需要校准视觉风格时才进入采集分文档。 + +## 标准交付物 + +每个主题目录使用 `kebab-case` 命名,例如 `stripe`、`longcipher-design`: + +```text +src/themes// +├── DESIGN.md # 必需,主题事实依据 +├── theme.json # 必需,结构化主题元数据与展示配置 +├── assets/ +│ ├── tokens.json # 必需,轻量 token 快照 +│ └── ... # 预览图、官网截图、字体、preview.html 等主题私有资源 +├── style.css # 必需,主题演示页样式变量 +├── tw.css # 必需,Tailwind v4 主题片段或最小可用片段 +└── index.tsx # 必需,主题演示页入口,必须 export default Component +``` + +## `DESIGN.md` 编写规范 + +`DESIGN.md` 应写成可执行的设计规范,而不是氛围描述。它需要同时面向人类和代码生成 agent,可直接指导后续 UI 生成、审查和派生文件同步。 + +信息充分时必须采用接近 getdesign.md 的 9 段式结构: + +1. 视觉主题与氛围:品牌/产品背景、适用场景、不适用场景、关键词、页面气质、信息密度和品牌表达边界。 +2. 色彩系统:主色、背景、表面、文本、边框、状态色、CTA 或限制色;每个重要颜色写清 hex 值、语义角色、使用边界和禁用场景。 +3. 字体系统:display/body/mono 角色、字体族、字号层级、行高、字重、字距和 fallback。 +4. 组件规范:按钮、输入框、卡片、导航、表格、标签、弹窗等基础组件;写清尺寸、圆角、边框、状态、hover/focus/active 行为和可复用类名或 token。 +5. 布局与间距:容器宽度、栅格、section 节奏、密度、断点前的默认布局、间距标尺和页面级留白。 +6. 深度、阴影与边框:阴影层级、边框/分割线、ring、elevation、表面叠放规则,以及不允许使用的过重阴影或装饰。 +7. 动效:时长、缓动、transform 模式、出现/退出/hover 规则,明确哪些动效可用、哪些必须避免。 +8. 响应式行为:desktop/tablet/mobile 下的布局变化、导航折叠、元素显隐、图片/表格/卡片重排策略。 +9. Prompt guide:给 LLM 的现成生成指令,包含 3-5 条推荐写法、3-5 条禁止写法和可直接复用的界面生成提示。 + +每一段都应尽量包含可落地数值、语义 token、组件状态或 Do/Don't。缺少来源证据时,可以写明“未采集到明确规则,按保守默认处理”,但不要伪造来源、品牌规则或组件细节。 + +生成或更新 `DESIGN.md` 时,优先使用用户提供的规范、原始 Design.md、官方设计资料和当前主题已有内容。截图和元数据只能用于补缺,不能覆盖明确规则。 + +## 派生文件规范 + +`theme.json` 必须承载管理端和演示页需要的结构化信息: + +- `identity.slug` 必须与主题目录名一致;`titleZh`、`descriptionZh` 面向管理端展示。 +- `tokens.palette`、`tokens.typography`、`tokens.radius`、`tokens.spacing` 等必须从 `DESIGN.md` 抽取或由用户确认。 +- `display.palette`、`display.typography`、`display.radius`、`display.spacing`、`display.borders`、`display.shadows`、`display.usageGuidance` 应服务于演示页展示,并与 `tokens` 同源。 +- `assets` 和 `previewImages` 只引用当前主题目录内资源,不引用本机绝对路径或其他主题资源。 + +`assets/tokens.json` 只保存轻量 token 快照: + +- 至少包含 `palette`、`typography`;若 `DESIGN.md` 提供圆角、间距、边框或阴影,也应保留对应字段。 +- 与 `theme.json.tokens` 保持一致,不额外发明另一套命名或语义。 + +`style.css` 用于让演示页真实体现主题: + +- 必须 `@import "tailwindcss";`。 +- 使用 `.dmb-page` 写入 `--dmb-*` CSS Variables,包括 accent、link、muted、background、font、radius、spacing、border 等可见变量。 +- 变量值必须来自 `DESIGN.md` 或 `theme.json.tokens`,不能为了好看临时换色。 + +`tw.css` 用于保留 Tailwind v4 主题片段: + +- 若来源提供 Tailwind/CSS 变量,应尽量原样保留并补齐 `@import "tailwindcss";`。 +- 若暂无可用内容,可保持最小可用片段,但不得替代 `DESIGN.md` 成为事实源。 + +## 演示页规范 + +`index.tsx` 是主题预览入口,必须: + +- `export default Component`。 +- 引入 `./style.css`,读取 `./theme.json`,并把 `display` 配置传入 `DesignMdBatchShowcase`。 +- 通过静态 import 引入本地预览资源,例如 `./assets/official-homepage.webp?url`。 +- 展示颜色、字体、圆角、间距、边框/阴影、使用建议和典型场景。 +- 不展示内部采集过程、脚本状态、TODO、占位文案或无关营销内容。 + +可以复用 `DesignMdBatchShowcase` 的结构,但主题内容、标签、色板、使用建议和预览图必须来自当前 `DESIGN.md`。 + +## 更新工作流 + +1. 先读用户要求、当前主题 `DESIGN.md`、`theme.json`、`assets/tokens.json`、`style.css`、`tw.css`、`index.tsx` 和相关资源。 +2. 判断冲突:用户明确修改意图优先;否则以 `DESIGN.md` 为准,修正派生文件。 +3. 修改事实源:新增或修正设计规则时先改 `DESIGN.md`。 +4. 同步派生:更新 `theme.json.tokens/display`、`assets/tokens.json`、`style.css`、`tw.css` 和预览资源引用。 +5. 检查一致性:主色是否来自 `DESIGN.md`,字体角色是否完整,圆角/间距/边框/阴影是否没有丢失,使用建议是否非泛化。 +6. 查找和导入:优先从 `getdesign.md` 和 `styles.refero.design` 定位主题,再用 `collect`、`generate`、`review` 三个脚本串起导入流程。 +7. 网页采集:仅在用户提供网页地址或现有证据不足时读取 `rules/theme-source-capture-guide.md`,把采集结果作为 `DESIGN.md` 的证据来源。 +8. 验收预览:运行主题 ready 检查并打开目标页面做视觉回归,确认字体、颜色、间距、建议项和预览图都能完整渲染。 + +输入来源优先级: + +1. 用户当前明确要求、附件、截图和链接。 +2. 当前主题 `DESIGN.md`。 +3. 当前主题 `theme.json`、`assets/tokens.json`、`style.css`、`tw.css`、`index.tsx`。 +4. 官方设计资料或原始 Design.md 来源。 +5. 标准参考主题 `src/themes/linear/` 和同类主题。 + +## 验收流程 + +基础检查: + +```bash +node scripts/check-app-ready.mjs /themes/[主题名] +``` + +验收重点: + +- `READY` 后访问目标页面,检查预览图、色板、字体、圆角、间距、边框、阴影和使用建议是否与 `DESIGN.md` 一致。 +- 若出现 `ERROR`,优先修复入口、资源路径、JSON 结构和 CSS 导入。 +- 若出现 `TIMEOUT`,排查 dev server、依赖安装、构建缓存或长任务。 +- 视觉问题按 `DESIGN.md`、用户要求、`rules/requirements-alignment-guide.md` 的顺序判断,不以自动生成结果为准。 diff --git a/rules/theme-source-capture-guide.md b/rules/theme-source-capture-guide.md new file mode 100644 index 0000000..c343e25 --- /dev/null +++ b/rules/theme-source-capture-guide.md @@ -0,0 +1,121 @@ +# 主题网页来源采集指南 + +本文档只处理“从网页收集主题证据”的场景。采集完成后,回到 `rules/theme-guide.md` 生成或更新 `src/themes//` 的标准交付物。 + +## 何时读取 + +- 用户提供 URL,并要求参考网站、提取主题、生成主题、分析设计风格或补预览图。 +- 当前主题缺少可靠预览图、色彩/字体/组件证据,无法安全补齐 `DESIGN.md`。 +- 需要校准响应式、首屏、section 节奏、产品截图风格或真实控件形态。 + +不需要读取的情况: + +- 用户已经提供明确的 `DESIGN.md`、品牌规范、设计稿或人工确认的 token。 +- 只是同步已有主题的 `theme.json`、`style.css`、`tw.css`、`index.tsx`。 +- 用户只要求局部改文案、标签、预览描述或演示页展示字段。 + +## 采集目标 + +采集是为了拿到足够证据,而不是克隆页面。优先收集: + +- 全页截图:判断视觉主题、页面节奏、信息层级和主要素材风格。 +- 响应式截图:desktop/tablet/mobile 的布局变化、导航折叠和卡片重排。 +- 设计 token:颜色、字体、字号、圆角、边框、阴影、过渡和 CSS 变量。 +- 关键 section:header、hero、核心产品展示、卡片组、footer 的截图或 computed style。 +- 预览资产:一张可稳定放入主题 `assets/` 的官网截图或裁切图。 + +## 推荐采集方式 + +优先使用项目脚本: + +```bash +pnpm run capture:theme-source -- --theme --url +``` + +也可以使用 Playwright、in-app Browser、Chrome 自动化或同等能力的工具。工具不是硬性绑定;只要能稳定获得截图、token 和必要页面证据,就可以使用等价工具。 + +建议落点: + +```text +.local/theme-capture-/ +├── screenshot.png +├── responsive/ +│ ├── desktop.png +│ ├── tablet.png +│ └── mobile.png +├── theme.json 或 computed-tokens.json +├── meta.json +└── sections/ # 可选 +``` + +不要把采集原始数据放入主题目录。主题目录只保留标准交付物和必要的稳定预览资源。 + +## JSON 如何获取 + +这些 JSON 是采集过程的中间证据,不是主题最终产物: + +- `meta.json`:页面元信息,通常由采集工具自动写入,至少包含来源 URL、页面标题、视口、采集时间。若使用等效工具,也可以手动写一个最小版本。 +- `theme.json`:结构化设计 token,通常由页面采集工具从 computed style 中统计生成,包括颜色、字体、字号、间距、圆角、边框、阴影、过渡、CSS 变量等。 +- `computed-tokens.json`:当 `theme.json` 没有生成或不可靠时,用 Playwright / Browser evaluate 在页面里抽样 `getComputedStyle` 后生成的替代摘要。 + +常见来源: + +```bash +# 项目默认采集入口:产出截图、响应式截图、theme.json、computed-tokens.json、meta.json +pnpm run capture:theme-source -- --theme --url + +# 有现成采集工具时,直接产出 theme.json / meta.json / screenshot.png +node /scripts/extract.mjs --theme --screenshot --scroll -o .local/theme-capture- + +# 或用支持 clone/page data 的工具采集截图、theme 和响应式数据 +node /scripts/clone.mjs quick -o .local/theme-capture- --scroll +node /scripts/clone.mjs responsive -o .local/theme-capture- +``` + +如果这些工具失败或不可用,就用 Playwright、in-app Browser、Chrome 自动化等效实现: + +1. 截图写入 `screenshot.png` 和 `responsive/*.png`。 +2. 在页面上下文执行 `getComputedStyle`,统计可见元素的颜色、字体、字号、圆角、边框、阴影和 transition。 +3. 把结果写成 `computed-tokens.json`,同时写一个包含 URL、title、viewport、timestamp 的 `meta.json`。 + +项目现有的 `scripts/capture-theme-homepage.mjs` 主要用于抓稳定官网预览图,不负责生成 `theme.json`;若只使用它,需要再用等效方式补 `theme.json` 或 `computed-tokens.json`。 + +## 证据优先级 + +1. 用户明确说明、附件、设计稿或品牌规范。 +2. 原始网页截图,尤其是全页截图和响应式截图。 +3. 页面 CSS 变量和官方命名 token。 +4. computed style / `theme.json` 的统计值。 +5. 自动推断结果。 + +当截图和 token 冲突时,以截图和用户说明为准。`theme.json` 或 computed style 常会混入 cookie 弹窗、第三方组件、浏览器默认值和隐藏元素,需要人工排除。 + +## 分析检查点 + +看截图时至少确认: + +- 品牌气质:极简、科技、温暖、高端、实验、工具感等。 +- 色彩策略:主背景、主文本、唯一或多个强调色、状态色边界。 +- 排版策略:标题大小/重量、正文字号、字距、代码字体。 +- 组件形态:按钮圆角、高度、边框、卡片表面、输入框、标签、弹窗。 +- 布局节奏:首屏留白、section 间距、容器宽度、网格或横向滚动。 +- 深度表达:阴影、边框、层级、毛玻璃、渐变、噪点或纯色面。 +- 响应式:导航是否折叠、CTA 是否全宽、卡片列数如何变化。 +- 素材风格:产品截图、摄影、3D、插画、图标或视频。 + +这些结论应写入 `DESIGN.md` 的 9 段式规范,而不是只留在交付说明里。 + +## 常见降级 + +- Playwright 浏览器缺失:可连接系统 Chrome、使用 in-app Browser、Chrome 自动化或其他截图工具。 +- `networkidle` 等待过久:改用 `domcontentloaded` 加固定等待,确保截图稳定即可。 +- token 脚本失败:保留截图,改用浏览器 evaluate 或手工 computed style 抽样补齐关键 token。 +- DOM 骨架或 section 采集失败:不阻塞主题生成;优先保留全页截图、响应式截图和关键视觉观察。 +- 第三方弹窗污染:在 `DESIGN.md` 中明确排除,不把其字体、按钮或颜色作为品牌 token。 + +## 采集后回填 + +- 把稳定预览图复制到 `src/themes//assets/`,路径必须是主题内相对路径。 +- 在 `theme.json.source` 标记网页来源和采集方式,`assets.previewHtml` 指向主题内预览图。 +- 在 `DESIGN.md` 开头写清来源 URL 和采集日期;若某些规则来自推断,明确写“按截图观察/保守默认处理”。 +- 同步 `theme.json.tokens`、`assets/tokens.json`、`style.css`、`tw.css`,不要让采集数据成为另一套事实源。 diff --git a/rules/ui-review-guide.md b/rules/ui-review-guide.md new file mode 100644 index 0000000..24bb6cd --- /dev/null +++ b/rules/ui-review-guide.md @@ -0,0 +1,157 @@ +# UI Review 指导 + +用于审查 Axhub Make client 原型页面的 UI 质量、设计一致性、响应式、可访问性和核心元件表现。 + +## 审查入口 + +当用户说「UI review」「审查这个页面」「检查设计质量」「帮我挑一下 UI 问题」时,读取本规则并输出 Markdown 评审结论。 + +需要参考 Impeccable 的 UI critique 方法时,只在本次评审中按需读取以下文件: + +- `rules/references/impeccable/SKILL.md` +- `rules/references/impeccable/reference/critique.md` + +`rules/references/impeccable/` 是第三方技能的完整归档参考,不是默认项目技能。不要调用 `/impeccable critique`,不要运行原技能的上下文注入流程,也不要因为缺少 `PRODUCT.md` 中断评审。 + +## 审查依据 + +本仓库业务原型审查依据 **固定为** OneOS V2 设计规范: + +1. **唯一规范正文**:`src/resources/design-system/DESIGN.md` +2. Token / CSS / 分章可作为一致性证据:`tokens.json`、`oneos-ds-tokens.css`、`chapters/` +3. **禁止** 使用 `src/themes//DESIGN.md` 作为业务原型审查依据(主题库仅演示用) + +禁止把以下内容作为审查依据: + +- `PRODUCT.md` +- 主题库 `theme.json`(非 design-system) +- 截图 +- README 或其他说明文档 + +`tokens.json` / CSS 变量可作为是否落实 V2 的证据,但不能替代 `DESIGN.md` 的规范地位。 + +## Impeccable 参考约束 + +读取 Impeccable 参考流程时,必须以内化方式覆盖以下 Axhub 约束: + +```text +Use the copied Impeccable critique reference as the review method, but follow Axhub rules: +1. Use only src/resources/design-system/DESIGN.md (OneOS V2) as the design basis. +2. Ignore PRODUCT.md and src/themes/*/DESIGN.md as normative criteria for prototypes. +3. Do not offer theme DESIGN.md candidates; V2 is mandatory. +4. Do not call /impeccable commands or run Impeccable context injection scripts. +5. Produce a Markdown report, not JSON. +6. Write the result to the target prototype .spec directory. +7. Include sections in order: 总体点评, P0-P3 优先级问题, 核心元件. +8. Priorities must contain at most 5 P0-P3 findings. +9. Do not write .impeccable critique artifacts as the deliverable. +``` + +如果 Impeccable 的原始流程要求 `PRODUCT.md`、`.impeccable/critique`、`.agents/skills/impeccable/` 或额外上下文注入,与本规则冲突时,以本规则为准。 + +## 推荐审查流程 + +1. **确定目标** + - 原型:`src/prototypes//` + - 原型内页面:保留 `pageId` + - 目标不清时,先问用户确认 + +2. **确定 DESIGN.md** + - 业务原型固定读取:`src/resources/design-system/DESIGN.md` + - 禁止改用 `src/themes//DESIGN.md` 作审查依据 + - 仅当用户明确审查「主题演示页」本身时,才可读对应主题 `DESIGN.md` + +3. **参考 Impeccable critique** + - 读取目标源码和本地样式 + - 有预览环境时检查桌面和移动端 + - 可用浏览器时保留截图证据 + - 允许使用 `rules/references/impeccable/scripts/detect.mjs` 作为辅助证据,但不要让 detector 输出先污染设计判断 + +4. **综合结论** + - 不直接拼接 Impeccable 原报告或归档原文 + - 按 Axhub Markdown 模板重组 + - P0-P3 问题最多 5 条 + - 必须包含核心元件或关键 UI 区块点评 + +5. **写入 `.spec`** + - 原型级:`src/prototypes//.spec/ui-review.md` + - 页面级如后续需要:`src/prototypes//.spec//ui-review.md` + +## Markdown 模板 + +```markdown +# UI Review + +- 审查目标:src/prototypes/ +- 使用设计依据:src/resources/design-system/DESIGN.md +- 生成时间:2026-05-22 00:00 + +## 总体点评 + +用 1-3 段总结整体设计质量、与 DESIGN.md 的一致性、主要风险和最值得保留的亮点。 + +## P0-P3 优先级问题 + +### P1 - Finding title + +- 证据:说明出现位置、截图/预览观察或源码线索。 +- 影响:说明对用户任务、理解、可访问性或品牌一致性的影响。 +- 修复方向:给出可执行的设计或实现建议。 + +## 核心元件 + +### Hero / Header / Form / Navigation + +按关键 UI 区块点评是否符合 DESIGN.md,指出保留点和调整点。 + +## 响应式与可访问性 + +记录桌面/移动端差异、键盘/语义/对比度等发现。没有明显问题时也要说明已检查。 + +## 证据与评估说明 + +- 浏览器/截图:说明是否使用。 +- Scanner:说明是否使用。 +- 独立评估:full 或 degraded,并说明原因。 +``` + +## 分组要求 + +前三组固定且顺序不可变: + +1. `总体点评` +2. `P0-P3 优先级问题`,最多 5 条 +3. `核心元件` + +可以追加额外分组,例如 `响应式与可访问性`、`证据与评估说明`,但必须放在前三组之后。 + +## 优先级 + +- `P0`:阻断核心任务完成,或违反 `DESIGN.md` 中强制规则 +- `P1`:显著增加用户完成任务的难度,或造成 WCAG AA 级别可访问性问题 +- `P2`:明显体验摩擦,但存在可用绕行 +- `P3`:低影响 polish,修复后更好但不影响主要任务 + +不要使用 `P4` 或更低优先级。 + +## 子代理与独立评估 + +有子代理能力时,优先拆成两个独立评估: + +- 设计评估:只看目标、`DESIGN.md`、截图/预览和源码 +- 证据评估:看 scanner、响应式、可访问性和实现风险 + +两个评估完成前不要互相暴露结论。没有子代理时,先完成设计评估笔记,再看 scanner/证据,并在 `证据与评估说明` 中标记独立评估为 `degraded`。 + +当审查 3 个以上独立页面或组件时,优先按目标拆分并行审查,最后统一综合成 `.spec/ui-review.md`。 + +## 交付说明 + +最终回复至少包含: + +- 审查目标 +- 使用的 `DESIGN.md` +- 写入的 `.spec/ui-review.md` 路径 +- P0-P3 数量 +- 是否使用浏览器/截图/scanner +- 独立评估是否完整 diff --git a/rules/v0-project-converter.md b/rules/v0-project-converter.md new file mode 100644 index 0000000..0024559 --- /dev/null +++ b/rules/v0-project-converter.md @@ -0,0 +1,111 @@ +# V0 项目转换规则 + +用于将 V0 生成的 Next.js 项目转换为本项目原型页面,保持视觉和功能,并符合 `rules/prototype-development-guide.md`。 + +## 目标 + +- 保持页面视觉一致性。 +- 移除 Next.js 特有实现。 +- 产出可在 `src/prototypes//` 中运行的 React 页面。 + +## 预处理 + +Make 服务端上传接口会自动运行内置 V0 预处理器。它会复制项目、分析路径别名和依赖,并生成任务文档与分析 JSON。预处理器不直接修改业务代码。 + +如果项目需要覆盖默认预处理行为,可以在目标项目中提供 `scripts/v0-converter.mjs`;否则使用服务端内置版本。 + +## 默认页面格式 + +默认转换为普通 React 页面。只有明确需要 Axhub / Axure 接管时才接入 Axure API。 + +```typescript +/** + * @name 页面名称 + * + * 参考资料: + * - /rules/prototype-development-guide.md + */ + +import './style.css'; +import React from 'react'; + +export default function PageName() { + return ( +
+ ); +} +``` + +需要 Axure API 时,再参考 `rules/axure-api-guide.md`。 + +## 移除 Next.js 代码 + +移除或替换: + +- `"use client"`:删除。 +- `next/navigation`:删除或改为组件内状态/普通链接。 +- `next/image`:改为 ``。 +- `next/link`:改为 ``。 +- `Metadata`、`@vercel/*`:删除。 + +## 路径别名 + +将 `@/` 转换为相对路径: + +```typescript +// V0 +import { cn } from "@/lib/utils"; + +// 转换后 +import { cn } from "../lib/utils"; +``` + +以脚本生成的分析表为准逐项检查。 + +## 样式 + +`style.css` 以 Tailwind V4 入口开头: + +```css +@import "tailwindcss"; +``` + +随后合并源项目全局样式文件、主题变量和自定义样式。 + +## 依赖 + +排除: + +- `next` 和 `next-*` +- `@vercel/*` +- `react` 和 `react-dom` + +保留并按需安装: + +- `class-variance-authority` +- `clsx` +- `tailwind-merge` +- `@radix-ui/*` +- `lucide-react` +- `recharts` +- `date-fns` + +新增依赖优先使用 npm,便于生成项目在没有 pnpm 的用户环境中继续运行: + +```bash +npm install +``` + +## 验收 + +```bash +node scripts/check-app-ready.mjs /prototypes/[页面名] +``` + +要求: + +- 状态为 `READY`。 +- 页面正常渲染。 +- 无控制台错误。 +- 关键交互正常。 +- 样式显示正确。 diff --git a/scripts/ai-studio-converter.mjs b/scripts/ai-studio-converter.mjs new file mode 100644 index 0000000..d6bef4f --- /dev/null +++ b/scripts/ai-studio-converter.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; + +function normalizeSlashes(input) { + return String(input || '').replace(/\\/g, '/'); +} + +function sanitizeName(rawName) { + return String(rawName || '') + .replace(/[^a-z0-9-]/gi, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .toLowerCase(); +} + +function parseArgs(argv) { + const args = [...argv]; + const projectDirArg = args.shift(); + const outputNameArg = args.shift(); + let targetType = 'prototypes'; + let projectRoot = process.cwd(); + let outputBaseDir = ''; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--target-type') { + targetType = String(args[index + 1] || '').trim(); + index += 1; + } else if (arg === '--project-root') { + projectRoot = path.resolve(args[index + 1] || projectRoot); + index += 1; + } else if (arg === '--output-base-dir') { + outputBaseDir = path.resolve(args[index + 1] || ''); + index += 1; + } + } + + if (!projectDirArg) throw new Error('Missing project directory'); + const outputName = sanitizeName(outputNameArg || path.basename(projectDirArg)); + if (!outputName) throw new Error('Missing valid output name'); + return { + projectDir: path.resolve(projectRoot, projectDirArg), + outputName, + targetType, + projectRoot, + outputBaseDir: outputBaseDir || path.resolve(projectRoot, 'src', targetType), + }; +} + +function copyDirectory(src, dest) { + if (!fs.existsSync(src)) return 0; + fs.mkdirSync(dest, { recursive: true }); + let count = 0; + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + if (entry.name === 'node_modules') continue; + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) count += copyDirectory(srcPath, destPath); + else if (entry.isFile()) { + fs.copyFileSync(srcPath, destPath); + count += 1; + } + } + return count; +} + +function ensureIndex(outputDir) { + const indexPath = path.join(outputDir, 'index.tsx'); + if (!fs.existsSync(indexPath)) { + fs.writeFileSync(indexPath, [ + "import React from 'react';", + '', + 'export default function ImportedAiStudioPrototype() {', + ' return
AI Studio import requires AI conversion.
;', + '}', + '', + ].join('\n'), 'utf8'); + } +} + +function main() { + const parsed = parseArgs(process.argv.slice(2)); + if (!fs.existsSync(path.join(parsed.projectDir, 'App.tsx')) && !fs.existsSync(path.join(parsed.projectDir, 'index.html'))) { + throw new Error('这不是一个有效的 AI Studio 项目(缺少 App.tsx 或 index.html)'); + } + const outputDir = path.join(parsed.outputBaseDir, parsed.outputName); + fs.rmSync(outputDir, { recursive: true, force: true }); + fs.mkdirSync(parsed.outputBaseDir, { recursive: true }); + const fileCount = copyDirectory(parsed.projectDir, outputDir); + ensureIndex(outputDir); + const taskPath = path.join(outputDir, parsed.targetType === 'themes' ? '.ai-studio-theme-tasks.md' : '.ai-studio-tasks.md'); + fs.writeFileSync(taskPath, [ + '# AI Studio 项目转换任务清单', + '', + '> 请先阅读 `rules/ai-studio-project-converter.md` 和 `rules/development-guide.md`,再基于该目录完成原型转换。', + '', + `- 输出目录:\`${normalizeSlashes(path.relative(parsed.projectRoot, outputDir))}/\``, + `- 已复制文件数:${fileCount}`, + '', + ].join('\n'), 'utf8'); + console.log(JSON.stringify({ + success: true, + outputDir, + tasksFile: normalizeSlashes(path.relative(parsed.projectRoot, taskPath)), + })); +} + +try { + main(); +} catch (error) { + console.error(error?.message || String(error)); + process.exit(1); +} diff --git a/scripts/apply-release-changelog.mjs b/scripts/apply-release-changelog.mjs new file mode 100644 index 0000000..7c10d7e --- /dev/null +++ b/scripts/apply-release-changelog.mjs @@ -0,0 +1,293 @@ +/** + * 将版本发布的功能变更说明写入 prototype-registry.json(覆盖 commit 131b963 对应条目)。 + * + * 用法:node scripts/apply-release-changelog.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, '..'); +const REGISTRY_PATH = path.join(projectRoot, 'src/prototypes/oneos-prototype-nav/prototype-registry.json'); +const RELEASE_COMMIT = '131b963'; +const RELEASE_DATE = '2026-07-12'; +const RELEASE_TIME = '22:45'; +const MAX_RECENT = 40; + +/** @type {Record} */ +const RELEASE_NOTES = { + 'oneos-prototype-nav': { + summary: '统一标注壳与操作规范,租赁明细/合同/提车应收款增强,新增任务工单,同步合包页面', + details: [ + '【平台】全项目接入 PrototypeAnnotationHost,PRD 迁入右侧标注目录;列表统一 OperationActions 与工具栏图标规范', + '【租赁业务明细】收款状态列、47 列宽表、氢费钻取、字段校验、变更记录、导入导出与主管解锁', + '【租赁合同】KPI/状态收敛、查看页、线上/线下签署、交付变更、附加费用与提车应收联动', + '【提车应收款】独立原型上线;finance 合包仅保留租赁账单;工作台快捷入口改跳独立原型', + '【任务工单】新增业管发起端:合同/独立双入口、六类任务类型与督办催办', + '【合包】business/ops/finance/ledger-data/lease-contract/h2-station 等移除面包屑并标准化操作区', + '【下线】移除 login/requirements/vehicle-asset 合包;侧栏新增任务工单与提车应收款', + '【发布】导航页固定发布至 /oneos-prototype-nav/index.html', + ], + }, + 'lease-business-detail': { + summary: '租赁业务明细大改版:收款状态、47 列宽表、字段校验与变更记录', + details: [ + '新增收款状态列(未收款/部分收款/已结清);月份、部门+业务员合并展示;表尾 23 项金额汇总', + '氢费可钻取 Popover;编辑/删除/变更记录;公式列(应收合计、未收、盈亏、氢费等)自动重算', + '客户/提车日期/租金等与车辆/客户/合同交叉校验,支持一键替换建议值', + '批量导入 36 列手工字段、导出 47 列;部分成功 + 错误日志;已对账锁定与主管解锁', + ], + }, + 'lease-contract-management': { + summary: '租赁合同增强:KPI/状态收敛、查看页、签署方式与提车应收联动', + details: [ + '列表 KPI:全部/草稿/进行中/审批中/已终止;合同状态收敛;在租车队 KPI 迁至车辆管理', + '筛选:合同模板类型 + 标准合同名称二级联动;统一流程入口(新增/续签/转正式/增车等)', + '新增只读查看页;线上电子签章 / 线下人工上传;主动终止与交付安排变更(Cascader/TBD)', + '附加费用拆分已有/新增服务项;红线条款 + 新增条款检测;先付后用/先用后付', + '提交审批与交付计划变更同步提车应收 bridge', + ], + }, + 'lease-business-ledger': { + summary: '租赁业务台账:操作区统一为 OperationActions,标注壳切换', + details: [ + '行操作:编辑 / 操作记录 / 保存(编辑态)', + '关联收款、事故/维保/收据链接弹窗保留', + '接入 PrototypeAnnotationHost,移除内嵌原型导航目录', + ], + }, + 'self-operated-business-ledger': { + summary: '物流业务明细:更名、列对齐 Excel、批量导入与自动计算', + details: [ + '模块更名:物流业务台账 → 物流业务明细', + '列对齐 Excel 1.2:薪资、电费、日社保/挂车/停车/轮胎、线路计价等;系统车型列', + '新增批量导入:模板下载、错误日志、导入后自动计算金额/总成本/盈亏', + '移除行勾选与批量删除', + ], + }, + 'business-dept-ledger': { + summary: '业务部台账:分组表头与表格可读性增强', + details: [ + '统一边框 token、分组表头双层样式(业务板块 + 业绩/成本/盈亏)', + '冻结列滚动修复;金额列防裁切;盈亏钻取链接样式调整', + ], + }, + 'vehicle-management': { + summary: '车辆管理:在租车队 KPI 迁入、车型分布弹窗与列表样式优化', + details: [ + '在租车队 KPI 从租赁合同列表迁入;按品牌型号统计在租数量', + '车型分布弹窗:品牌筛选、车型卡片、占比进度条、车辆明细', + '详情 Tab 与列表筛选/导入/KPI 操作区统一;表格样式优化', + ], + }, + 'customer-management': { + summary: '客户管理:OperationActions 统一操作列,移除面包屑', + details: [ + '列表操作:详情 / 编辑 / 管理标签 / 删除', + '风险标签、批量打标等业务逻辑不变', + ], + }, + 'contract-template-management': { + summary: '合同模板:启用/停用、先付后用/先用后付变量与操作区重构', + details: [ + '行操作:编辑 + 更多(启用/停用、版本日志、删除);启用中不可编辑/删除', + '模板变量新增 paymentMethod;付款周期与氢费结算文案更新', + ], + }, + 'insurance-procurement': { + summary: '保险采购:OperationActions 统一操作列', + details: [ + '保单列表:编辑 / 预览 / 下载 / 更多', + '标注壳切换', + ], + }, + 'vehicle-pickup-receivable': { + summary: '提车应收款独立原型上线:自动生成/手动办理与合同双向联动', + details: [ + '收款情况列:租车数 / 已提车(Popover 钻取)', + '合同提交审批即生成主表;有交付时间则提前 2 天生成应收', + '手动办理勾选品牌型号与交付时间;15 日前/后分档计费规则', + '与租赁合同 bridge 联动;finance 合包剥离;工作台入口改跳本原型', + ], + }, + 'task-work-order': { + summary: '任务工单新原型:业管发起端(合同/独立双入口)', + details: [ + '任务类型:里程履约、维保履约、政策兑现、付款/交付节点、条款跟进、通用临时', + '列表 KPI:全部/我发布的/我督办的 + 状态视角;详情含进度、反馈、催办', + '执行人不可见合同原文与金额(第一版仅业管端)', + ], + }, + 'payment-records': { + summary: '收款记录:OperationActions 与关联账单操作', + details: [ + '操作列标准化;表格新增「关联账单」', + '标注壳切换', + ], + }, + 'supplier-management': { + summary: '供应商管理:工具栏与操作区图标标准化', + details: [ + '新增供应商按钮图标统一;删除收入 OperationActions', + ], + }, + 'oneos-web-business': { + summary: '业务管理合包:18 页移除面包屑,OperationActions 统一', + details: [ + '保险采购、交车任务、客户/供应商、租赁账单、ETC 管理等页操作区标准化', + ], + }, + 'oneos-web-ops': { + summary: '运维管理合包:45 页操作列与工具栏按钮标准化', + details: [ + '备件/调拨/交还车/故障/异动/证件/型号参数等页 OperationActions 与 data-vm-icon 图标', + ], + }, + 'oneos-web-finance': { + summary: '财务管理合包:提车应收款剥离,仅保留租赁账单', + details: [ + '提车应收款 6 页移除;合包默认页改为租赁账单', + '提车流程由 vehicle-pickup-receivable 独立原型承接', + ], + }, + 'oneos-web-ledger-data': { + summary: '台账数据合包:5 页操作区标准化', + details: [ + '保险分摊、氢费、维修明细、氢费采购端汇总、租赁业务台账等页 OperationActions', + ], + }, + 'oneos-web-lease-contract': { + summary: '租赁合同合包:8 子页同步签署/流程字段与 OperationActions', + details: [ + '新增、续签、转正式、三方变更、附加费用等子页操作区标准化', + '修复移除面包屑后的 JSX 括号语法', + ], + }, + 'oneos-web-data-analysis': { + summary: '数据分析合包:业务部台账列宽与盈亏着色优化', + details: [ + '9 页操作区标准化;业务部台账列宽加大;盈亏负值着色', + ], + }, + 'oneos-web-h2-station': { + summary: '加氢站合包:站点信息编辑提升为主操作', + details: [ + '加氢订单/记录/站点信息 3 页 OperationActions;站点编辑不再藏在「更多」', + ], + }, + 'oneos-web-workbench': { + summary: '工作台:提车应收款快捷入口改跳独立原型', + details: [ + '快捷入口指向 /prototypes/vehicle-pickup-receivable', + 'protoNav 支持直接 location 跳转原型 URL', + ], + }, + 'oneos-web-contract-template': { + summary: '合同模板合包:操作区与独立原型对齐', + details: ['模板管理页 OperationActions 统一'], + }, + 'oneos-web-procurement': { + summary: '采购管理合包:三方退租车页操作区标准化', + details: ['退租申请与管理页 OperationActions'], + }, + 'oneos-web-h2-station-site': { + summary: '站点信息:标注目录清理', + details: ['移除内嵌原型导航节点;标注壳切换'], + }, + 'lease-business-line-overview': { + summary: '业务条线说明:标注目录微调', + details: ['条线数据更新;移除内嵌导航节点'], + }, + 'vehicle-h2-fee-ledger': { + summary: '车辆氢费明细:标注壳与操作区统一', + details: ['OperationActions;标注目录规范化'], + }, + 'vehicle-maintenance-ledger': { + summary: '车辆维修明细:标注壳与操作区统一', + details: ['OperationActions;标注目录规范化'], + }, + 'vehicle-return-settlement-v2': { + summary: '还车应结算 V2:OperationActions 统一为详情主操作', + details: ['列表操作列标准化'], + }, +}; + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function applyReleaseNotes(registry) { + let updated = 0; + for (const [prototypeId, notes] of Object.entries(RELEASE_NOTES)) { + const record = registry.prototypes?.[prototypeId]; + if (!record) continue; + + const changelog = Array.isArray(record.changelog) ? [...record.changelog] : []; + let targetIndex = changelog.findIndex((entry) => entry.commit === RELEASE_COMMIT); + if (targetIndex < 0 && changelog.length > 0) targetIndex = 0; + + const base = targetIndex >= 0 ? changelog[targetIndex] : { + version: record.version, + date: RELEASE_DATE, + time: RELEASE_TIME, + files: record.trackedFiles?.slice(0, 12) || [], + }; + + const nextEntry = { + ...base, + date: RELEASE_DATE, + time: RELEASE_TIME, + summary: notes.summary, + details: notes.details, + commit: RELEASE_COMMIT, + }; + + if (targetIndex >= 0) { + changelog[targetIndex] = nextEntry; + } else { + changelog.unshift(nextEntry); + } + + record.changelog = changelog.slice(0, 30); + record.lastUpdated = `${RELEASE_DATE}T14:45:00.000Z`; + updated += 1; + } + + const recent = []; + for (const [prototypeId, notes] of Object.entries(RELEASE_NOTES)) { + const record = registry.prototypes?.[prototypeId]; + if (!record) continue; + const entry = record.changelog?.[0]; + if (!entry) continue; + recent.push({ + prototypeId, + title: record.title || prototypeId, + version: entry.version || record.version, + date: entry.date || RELEASE_DATE, + time: entry.time || RELEASE_TIME, + summary: notes.summary, + details: notes.details, + files: entry.files || [], + }); + } + + recent.sort((a, b) => { + const ta = new Date(`${a.date}T${a.time}:00+08:00`).getTime(); + const tb = new Date(`${b.date}T${b.time}:00+08:00`).getTime(); + return tb - ta; + }); + + registry.recentUpdates = recent.slice(0, MAX_RECENT); + registry.updatedAt = `${RELEASE_DATE}T14:45:00.000Z`; + return updated; +} + +const registry = readJson(REGISTRY_PATH); +const count = applyReleaseNotes(registry); +writeJson(REGISTRY_PATH, registry); +console.log(`完成:已写入 ${count} 个原型的版本发布变更说明(commit ${RELEASE_COMMIT})`); diff --git a/scripts/build-all.js b/scripts/build-all.js new file mode 100644 index 0000000..2613796 --- /dev/null +++ b/scripts/build-all.js @@ -0,0 +1,48 @@ +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { scanProjectEntries, writeEntriesManifestAtomic } from '../vite-plugins/utils/entriesManifestCore.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const workspaceRoot = path.resolve(__dirname, '..'); +const entriesPath = path.resolve(workspaceRoot, '.axhub/make/entries.json'); + +// Always rescan before build to keep entries manifest fresh and deterministic. +const scanned = scanProjectEntries(workspaceRoot, ['prototypes', 'themes']); +const entries = writeEntriesManifestAtomic(workspaceRoot, scanned); +if (!fs.existsSync(entriesPath)) { + console.error('.axhub/make/entries.json 写入失败,无法继续构建。'); + process.exit(1); +} + +const jsEntries = entries.js || {}; +const entryKeys = Object.keys(jsEntries); + +if (entryKeys.length === 0) { + console.log('未发现 JS 入口,跳过构建。'); + process.exit(0); +} + +const distDir = path.resolve(workspaceRoot, 'dist'); +if (fs.existsSync(distDir)) { + fs.rmSync(distDir, { recursive: true, force: true }); +} + +for (const key of entryKeys) { + console.log(`\n==== 构建入口: ${key} ====\n`); + const result = spawnSync('npx', ['vite', 'build'], { + cwd: workspaceRoot, + env: { ...process.env, ENTRY_KEY: key }, + stdio: 'inherit' + }); + + if (result.status !== 0) { + console.error(`构建 ${key} 失败,退出码 ${result.status}`); + process.exit(result.status ?? 1); + } +} + +console.log('\n所有入口构建完成 ✅'); diff --git a/scripts/canvas-fig-sync.mjs b/scripts/canvas-fig-sync.mjs new file mode 100644 index 0000000..d14de15 --- /dev/null +++ b/scripts/canvas-fig-sync.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../../vendor/axhub-export-core/scripts/canvas-fig-sync.mjs'; diff --git a/scripts/capture-theme-homepage.mjs b/scripts/capture-theme-homepage.mjs new file mode 100644 index 0000000..9a74a5a --- /dev/null +++ b/scripts/capture-theme-homepage.mjs @@ -0,0 +1,1025 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; + +const DEFAULT_CAPTURE_OPTIONS = { + viewport: { width: 1440, height: 1200 }, + deviceScaleFactor: 1, + format: 'webp', + quality: 88, + timeoutMs: 60000, + settleTimeoutMs: 10000, + waitAfterLoadMs: 0, + waitUntil: 'load', + hideScrollbar: true, + scrollWarmup: true, + maxScrollSteps: 80, + maxScreenshotHeight: 16383, + headless: true, +}; + +const PLAYWRIGHT_CACHE_DIR = path.join(os.homedir(), '.cache', 'axure-extractor'); +const CHROMIUM_ARGS = [ + '--allow-file-access-from-files', + '--disable-web-security', + '--disable-blink-features=AutomationControlled', + '--ignore-certificate-errors', + '--no-sandbox', +]; + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function resolveMaybePath(value, baseDir) { + if (!value) return value; + return path.isAbsolute(value) ? value : path.resolve(baseDir, value); +} + +function parseNumber(value, name) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new Error(`Invalid ${name}: ${value}`); + } + return parsed; +} + +function parseInteger(value, name) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + throw new Error(`Invalid ${name}: ${value}`); + } + return parsed; +} + +export function normalizeViewport(value) { + if (isObject(value)) { + const width = parseInteger(value.width, 'viewport width'); + const height = parseInteger(value.height, 'viewport height'); + if (width <= 0 || height <= 0) { + throw new Error(`Invalid viewport: ${JSON.stringify(value)}`); + } + return { width, height }; + } + + if (typeof value !== 'string') { + throw new Error(`Invalid viewport: ${String(value)}`); + } + + const match = value.trim().match(/^(\d+)x(\d+)$/i); + if (!match) { + throw new Error(`Invalid viewport: ${value}`); + } + + return { + width: parseInteger(match[1], 'viewport width'), + height: parseInteger(match[2], 'viewport height'), + }; +} + +function parseHeader(value) { + const separatorIndex = value.includes('=') ? value.indexOf('=') : value.indexOf(':'); + if (separatorIndex <= 0) { + throw new Error(`Invalid header, expected Name=Value: ${value}`); + } + + const name = value.slice(0, separatorIndex).trim(); + const headerValue = value.slice(separatorIndex + 1).trim(); + if (!name) { + throw new Error(`Invalid header, expected Name=Value: ${value}`); + } + + return [name, headerValue]; +} + +function readRequiredValue(argv, index, flag) { + const value = argv[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`${flag} requires a value`); + } + return value; +} + +export function parseCaptureArgs(argv = process.argv) { + const args = { + all: false, + help: false, + dryRun: false, + waitForSelectors: [], + dismissSelectors: [], + removeSelectors: [], + unstickSelectors: [], + headers: {}, + }; + + const rawArgs = argv.slice(2); + for (let i = 0; i < rawArgs.length; i += 1) { + const flag = rawArgs[i]; + + if (flag === '--') { + continue; + } else if (flag === '--help' || flag === '-h') { + args.help = true; + } else if (flag === '--all') { + args.all = true; + } else if (flag === '--dry-run') { + args.dryRun = true; + } else if (flag === '--theme') { + args.theme = readRequiredValue(rawArgs, i, flag); + i += 1; + } else if (flag === '--url') { + args.url = readRequiredValue(rawArgs, i, flag); + i += 1; + } else if (flag === '--config') { + args.config = readRequiredValue(rawArgs, i, flag); + i += 1; + } else if (flag === '--output') { + args.output = readRequiredValue(rawArgs, i, flag); + i += 1; + } else if (flag === '--capture-root') { + args.captureRoot = readRequiredValue(rawArgs, i, flag); + i += 1; + } else if (flag === '--viewport') { + args.viewport = normalizeViewport(readRequiredValue(rawArgs, i, flag)); + i += 1; + } else if (flag === '--device-scale-factor') { + args.deviceScaleFactor = parseNumber(readRequiredValue(rawArgs, i, flag), flag); + i += 1; + } else if (flag === '--format') { + args.format = readRequiredValue(rawArgs, i, flag).toLowerCase(); + i += 1; + } else if (flag === '--quality') { + args.quality = parseInteger(readRequiredValue(rawArgs, i, flag), flag); + i += 1; + } else if (flag === '--timeout') { + args.timeoutMs = parseInteger(readRequiredValue(rawArgs, i, flag), flag); + i += 1; + } else if (flag === '--settle-timeout') { + args.settleTimeoutMs = parseInteger(readRequiredValue(rawArgs, i, flag), flag); + i += 1; + } else if (flag === '--wait') { + args.waitAfterLoadMs = parseInteger(readRequiredValue(rawArgs, i, flag), flag); + i += 1; + } else if (flag === '--wait-until') { + args.waitUntil = readRequiredValue(rawArgs, i, flag); + i += 1; + } else if (flag === '--max-scroll-steps') { + args.maxScrollSteps = parseInteger(readRequiredValue(rawArgs, i, flag), flag); + i += 1; + } else if (flag === '--max-screenshot-height') { + args.maxScreenshotHeight = parseInteger(readRequiredValue(rawArgs, i, flag), flag); + i += 1; + } else if (flag === '--hide-scrollbar') { + args.hideScrollbar = true; + } else if (flag === '--show-scrollbar') { + args.hideScrollbar = false; + } else if (flag === '--scroll-warmup') { + args.scrollWarmup = true; + } else if (flag === '--no-scroll-warmup') { + args.scrollWarmup = false; + } else if (flag === '--headless') { + args.headless = true; + } else if (flag === '--no-headless') { + args.headless = false; + } else if (flag === '--connect-cdp') { + args.connectCdp = readRequiredValue(rawArgs, i, flag); + i += 1; + } else if (flag === '--browser-executable') { + args.browserExecutable = readRequiredValue(rawArgs, i, flag); + i += 1; + } else if (flag === '--storage-state') { + args.storageState = readRequiredValue(rawArgs, i, flag); + i += 1; + } else if (flag === '--cookie-file') { + args.cookieFile = readRequiredValue(rawArgs, i, flag); + i += 1; + } else if (flag === '--http-user') { + args.httpUser = readRequiredValue(rawArgs, i, flag); + i += 1; + } else if (flag === '--http-pass') { + args.httpPass = readRequiredValue(rawArgs, i, flag); + i += 1; + } else if (flag === '--header') { + const [name, value] = parseHeader(readRequiredValue(rawArgs, i, flag)); + args.headers[name] = value; + i += 1; + } else if (flag === '--wait-for-selector') { + args.waitForSelectors.push(readRequiredValue(rawArgs, i, flag)); + i += 1; + } else if (flag === '--dismiss-selector') { + args.dismissSelectors.push(readRequiredValue(rawArgs, i, flag)); + i += 1; + } else if (flag === '--remove-selector') { + args.removeSelectors.push(readRequiredValue(rawArgs, i, flag)); + i += 1; + } else if (flag === '--unstick-selector') { + args.unstickSelectors.push(readRequiredValue(rawArgs, i, flag)); + i += 1; + } else if (flag.startsWith('--')) { + throw new Error(`Unknown option: ${flag}`); + } else if (!args.theme) { + args.theme = flag; + } else if (!args.url) { + args.url = flag; + } else { + throw new Error(`Unexpected argument: ${flag}`); + } + } + + return args; +} + +export function findWorkspaceRoot(startDir = process.cwd()) { + let current = path.resolve(startDir); + while (true) { + if (fs.existsSync(path.join(current, 'pnpm-workspace.yaml'))) { + return current; + } + + const parent = path.dirname(current); + if (parent === current) { + return path.resolve(startDir); + } + current = parent; + } +} + +function normalizeSelectorList(value) { + if (!value) return []; + if (Array.isArray(value)) return value.filter(Boolean).map(String); + return [String(value)]; +} + +function normalizeHeaders(value) { + if (!value) return {}; + if (Array.isArray(value)) { + return Object.fromEntries(value.map((item) => parseHeader(String(item)))); + } + if (isObject(value)) { + return Object.fromEntries(Object.entries(value).map(([key, val]) => [key, String(val)])); + } + throw new Error('headers must be an object or array'); +} + +function normalizeOptionBag(value = {}) { + const bag = { ...value }; + if (bag.viewport) bag.viewport = normalizeViewport(bag.viewport); + if (bag.headers) bag.headers = normalizeHeaders(bag.headers); + if (bag.waitForSelectors) bag.waitForSelectors = normalizeSelectorList(bag.waitForSelectors); + if (bag.dismissSelectors) bag.dismissSelectors = normalizeSelectorList(bag.dismissSelectors); + if (bag.removeSelectors) bag.removeSelectors = normalizeSelectorList(bag.removeSelectors); + if (bag.unstickSelectors) bag.unstickSelectors = normalizeSelectorList(bag.unstickSelectors); + if (bag.format) bag.format = String(bag.format).toLowerCase(); + return bag; +} + +function normalizeConfigThemes(themes) { + if (!themes) return []; + + if (Array.isArray(themes)) { + return themes.map((entry) => { + if (typeof entry === 'string') { + return { theme: entry }; + } + return { ...entry }; + }); + } + + if (isObject(themes)) { + return Object.entries(themes).map(([theme, entry]) => { + if (typeof entry === 'string') { + return { theme, url: entry }; + } + return { theme, ...entry }; + }); + } + + throw new Error('config.themes must be an object or array'); +} + +function readThemeSource(theme, appRoot) { + const themeJsonPath = path.join(appRoot, 'src', 'themes', theme, 'theme.json'); + if (!fs.existsSync(themeJsonPath)) { + return {}; + } + + const themeJson = readJson(themeJsonPath); + return themeJson.source || {}; +} + +function inferThemeFromUrl(url) { + try { + return new URL(url).hostname.replace(/^www\./, '').split('.')[0] || 'homepage'; + } catch { + return 'homepage'; + } +} + +function firstNonEmpty(...values) { + return values.find((value) => typeof value === 'string' && value.length > 0); +} + +function pickCaptureOptions(args) { + const optionKeys = [ + 'output', + 'captureRoot', + 'viewport', + 'deviceScaleFactor', + 'format', + 'quality', + 'timeoutMs', + 'settleTimeoutMs', + 'waitAfterLoadMs', + 'waitUntil', + 'hideScrollbar', + 'scrollWarmup', + 'maxScrollSteps', + 'maxScreenshotHeight', + 'headless', + 'connectCdp', + 'browserExecutable', + 'storageState', + 'cookieFile', + 'httpUser', + 'httpPass', + 'headers', + 'waitForSelectors', + 'dismissSelectors', + 'removeSelectors', + 'unstickSelectors', + ]; + + return Object.fromEntries( + optionKeys + .filter((key) => args[key] !== undefined) + .map((key) => [key, args[key]]), + ); +} + +function mergeJobOptions(...bags) { + const job = {}; + for (const rawBag of bags) { + const bag = normalizeOptionBag(rawBag); + for (const [key, value] of Object.entries(bag)) { + if (value === undefined) continue; + if ( + key === 'waitForSelectors' + || key === 'dismissSelectors' + || key === 'removeSelectors' + || key === 'unstickSelectors' + ) { + job[key] = [...(job[key] || []), ...normalizeSelectorList(value)]; + } else if (key === 'headers') { + job.headers = { ...(job.headers || {}), ...normalizeHeaders(value) }; + } else { + job[key] = value; + } + } + } + return job; +} + +function resolveOutputPath({ output, theme, format, appRoot, multiple }) { + if (!output) { + return path.join(appRoot, 'src', 'themes', theme, 'assets', `official-homepage.${format}`); + } + + const resolved = resolveMaybePath(output, appRoot); + const extension = path.extname(resolved); + if (extension && !multiple) { + return resolved; + } + return path.join(resolved, theme, `official-homepage.${format}`); +} + +function resolveConfigPath(configPath, appRoot) { + return resolveMaybePath(configPath, appRoot); +} + +export function resolveCaptureJobs(args, options = {}) { + const appRoot = path.resolve(options.appRoot || process.cwd()); + const workspaceRoot = path.resolve(options.workspaceRoot || findWorkspaceRoot(appRoot)); + const config = args.config ? readJson(resolveConfigPath(args.config, appRoot)) : {}; + const configThemes = normalizeConfigThemes(config.themes); + + let entries = []; + if (args.all) { + if (configThemes.length === 0) { + throw new Error('--all requires a config file with themes'); + } + entries = configThemes; + } else { + const theme = args.theme || config.theme || (args.url ? inferThemeFromUrl(args.url) : null); + if (!theme) { + throw new Error('A theme is required. Pass --theme or --all with a config file.'); + } + const configEntry = configThemes.find((entry) => entry.theme === theme) || {}; + entries = [{ ...configEntry, theme }]; + } + + return entries.map((entry) => { + const theme = entry.theme || inferThemeFromUrl(entry.url || args.url || ''); + const source = readThemeSource(theme, appRoot); + const baseOptions = mergeJobOptions( + DEFAULT_CAPTURE_OPTIONS, + config.defaults || {}, + entry, + pickCaptureOptions(args), + ); + const format = baseOptions.format || DEFAULT_CAPTURE_OPTIONS.format; + if (!['png', 'jpeg', 'webp'].includes(format)) { + throw new Error(`Unsupported screenshot format: ${format}`); + } + + const url = firstNonEmpty(args.url, entry.url, source.websiteUrl, source.originalDetailUrl); + if (!url) { + throw new Error(`No URL found for theme "${theme}". Pass --url or add source.websiteUrl to theme.json.`); + } + + const captureRoot = resolveMaybePath( + baseOptions.captureRoot || path.join(workspaceRoot, '.local', 'theme-captures'), + appRoot, + ); + const reportDir = path.join(captureRoot, theme); + const outputPath = resolveOutputPath({ + output: baseOptions.output, + theme, + format, + appRoot, + multiple: entries.length > 1, + }); + + return { + ...baseOptions, + theme, + url, + format, + appRoot, + workspaceRoot, + captureRoot, + reportDir, + reportPath: path.join(reportDir, 'meta.json'), + outputPath, + storageState: resolveMaybePath(baseOptions.storageState, appRoot), + cookieFile: resolveMaybePath(baseOptions.cookieFile, appRoot), + }; + }); +} + +async function loadPlaywright() { + try { + return await import('playwright'); + } catch { + const cachedPath = path.join(PLAYWRIGHT_CACHE_DIR, 'node_modules', 'playwright', 'index.mjs'); + if (fs.existsSync(cachedPath)) { + return import(pathToFileURL(cachedPath).href); + } + } + + throw new Error( + 'Playwright is not available. Install it with npm, or run the existing clone-page tool once to populate the shared cache.', + ); +} + +function chromiumCandidatePaths(platform = process.platform) { + if (platform === 'darwin') { + return [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + ]; + } + + if (platform === 'win32') { + return [ + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', + ]; + } + + return [ + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/snap/bin/chromium', + ]; +} + +export function resolveChromiumLaunchOptions(job, options = {}) { + const exists = options.exists || fs.existsSync; + const candidatePaths = options.candidatePaths || chromiumCandidatePaths(); + const executablePath = job.browserExecutable + || candidatePaths.find((candidate) => exists(candidate)); + + return { + headless: job.headless, + args: CHROMIUM_ARGS, + ...(executablePath ? { executablePath } : {}), + }; +} + +export function resolveScreenshotPlan(job) { + if (job.format === 'webp') { + return { + playwrightType: 'png', + screenshotPath: path.join(job.reportDir, 'official-homepage-source.png'), + outputPath: job.outputPath, + needsConversion: true, + }; + } + + return { + playwrightType: job.format, + screenshotPath: job.outputPath, + outputPath: job.outputPath, + needsConversion: false, + }; +} + +export function resolveScreenshotBox(job, visualState) { + const sourceHeight = Math.max( + visualState?.scrollHeight || 0, + visualState?.bodyHeight || 0, + job.viewport.height, + ); + const maxHeight = job.maxScreenshotHeight; + const shouldClip = job.format === 'webp' + && Number.isFinite(maxHeight) + && maxHeight > 0 + && sourceHeight > maxHeight; + + if (shouldClip) { + return { + fullPage: false, + viewport: { width: job.viewport.width, height: maxHeight }, + clipped: true, + sourceHeight, + outputHeight: maxHeight, + }; + } + + return { + fullPage: true, + clipped: false, + sourceHeight, + outputHeight: sourceHeight, + }; +} + +function findExecutable(command, envPath = process.env.PATH || '') { + const names = process.platform === 'win32' && !command.endsWith('.exe') + ? [command, `${command}.exe`] + : [command]; + + for (const dir of envPath.split(path.delimiter)) { + for (const name of names) { + const candidate = path.join(dir, name); + if (fs.existsSync(candidate)) return candidate; + } + } + return null; +} + +function convertToWebp(inputPath, outputPath, quality) { + const cwebp = findExecutable('cwebp'); + if (cwebp) { + execFileSync(cwebp, ['-quiet', '-q', String(quality), inputPath, '-o', outputPath]); + return 'cwebp'; + } + + const magick = findExecutable('magick'); + if (magick) { + execFileSync(magick, [inputPath, '-quality', String(quality), outputPath]); + return 'magick'; + } + + const convert = findExecutable('convert'); + if (convert) { + execFileSync(convert, [inputPath, '-quality', String(quality), outputPath]); + return 'convert'; + } + + throw new Error('WebP output requires cwebp, ImageMagick magick, or ImageMagick convert in PATH.'); +} + +function identifyImage(imagePath) { + const magick = findExecutable('magick'); + const identify = magick ? [magick, 'identify'] : [findExecutable('identify') || 'identify']; + const output = execFileSync(identify[0], [...identify.slice(1), '-format', '%w %h', imagePath], { + encoding: 'utf8', + }).trim(); + const [width, height] = output.split(/\s+/).map((value) => Number.parseInt(value, 10)); + return { width, height }; +} + +export function resolveWidthNormalization(job, image) { + const width = Math.min(job.viewport.width, image.width); + return { + needed: image.width !== job.viewport.width, + width, + height: image.height, + x: 0, + y: 0, + }; +} + +function normalizeImageWidth(imagePath, job) { + const image = identifyImage(imagePath); + const normalize = resolveWidthNormalization(job, image); + if (!normalize.needed) { + return { ...normalize, originalWidth: image.width, originalHeight: image.height, tool: null }; + } + + const magick = findExecutable('magick'); + if (magick) { + execFileSync(magick, [ + imagePath, + '-crop', + `${normalize.width}x${normalize.height}+${normalize.x}+${normalize.y}`, + '+repage', + imagePath, + ]); + return { ...normalize, originalWidth: image.width, originalHeight: image.height, tool: 'magick' }; + } + + const convert = findExecutable('convert'); + if (convert) { + execFileSync(convert, [ + imagePath, + '-crop', + `${normalize.width}x${normalize.height}+${normalize.x}+${normalize.y}`, + '+repage', + imagePath, + ]); + return { ...normalize, originalWidth: image.width, originalHeight: image.height, tool: 'convert' }; + } + + throw new Error('Width normalization requires ImageMagick magick or convert in PATH.'); +} + +async function openPage(playwright, job) { + let browser; + let context; + let page; + let ownsContext = true; + + if (job.connectCdp) { + browser = await playwright.chromium.connectOverCDP(job.connectCdp); + context = browser.contexts()[0] || await browser.newContext(); + ownsContext = browser.contexts()[0] !== context; + page = await context.newPage(); + await page.setViewportSize(job.viewport); + await context.setExtraHTTPHeaders(job.headers || {}); + } else { + browser = await playwright.chromium.launch(resolveChromiumLaunchOptions(job)); + context = await browser.newContext({ + viewport: job.viewport, + deviceScaleFactor: job.deviceScaleFactor, + storageState: job.storageState && fs.existsSync(job.storageState) ? job.storageState : undefined, + extraHTTPHeaders: job.headers, + httpCredentials: job.httpUser && job.httpPass + ? { username: job.httpUser, password: job.httpPass } + : undefined, + ignoreHTTPSErrors: true, + }); + page = await context.newPage(); + } + + if (job.cookieFile) { + const cookies = readJson(job.cookieFile); + await context.addCookies(Array.isArray(cookies) ? cookies : cookies.cookies || []); + } + + return { browser, context, page, ownsContext }; +} + +async function injectCaptureCss(page, job) { + const scrollbarCss = job.hideScrollbar + ? ` +html, body { + scrollbar-width: none !important; + -ms-overflow-style: none !important; +} +html::-webkit-scrollbar, +body::-webkit-scrollbar, +*::-webkit-scrollbar { + width: 0 !important; + height: 0 !important; + display: none !important; +} +` + : ''; + + const removeCss = (job.removeSelectors || []) + .map((selector) => `${selector} { display: none !important; visibility: hidden !important; }`) + .join('\n'); + const unstickCss = (job.unstickSelectors || []) + .map((selector) => `${selector} { position: static !important; top: auto !important; }`) + .join('\n'); + + await page.addStyleTag({ + content: ` +${scrollbarCss} +* { + caret-color: transparent !important; +} +${removeCss} +${unstickCss} +`, + }); +} + +async function dismissOverlays(page, selectors) { + for (const selector of selectors || []) { + try { + const locator = page.locator(selector).first(); + await locator.click({ timeout: 1500 }); + await page.waitForTimeout(300); + } catch { + // Optional dismiss selectors should never fail a capture. + } + } +} + +async function waitForSelectors(page, selectors, timeoutMs) { + for (const selector of selectors || []) { + await page.waitForSelector(selector, { timeout: timeoutMs, state: 'attached' }); + } +} + +async function waitForFontsAndImages(page, timeoutMs) { + await page.evaluate(async (timeout) => { + const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const withTimeout = (promise) => Promise.race([promise, delay(timeout)]); + + if (document.fonts?.ready) { + await withTimeout(document.fonts.ready); + } + + const pendingImages = Array.from(document.images).filter((image) => !image.complete); + await withTimeout(Promise.all(pendingImages.map((image) => new Promise((resolve) => { + image.addEventListener('load', resolve, { once: true }); + image.addEventListener('error', resolve, { once: true }); + })))); + }, Math.min(timeoutMs, 8000)); +} + +async function scrollWarmup(page, job) { + const result = await page.evaluate(async ({ maxScrollSteps }) => { + const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const scrollingElement = document.scrollingElement || document.documentElement; + const step = Math.max(600, Math.floor(window.innerHeight * 0.85)); + let previousHeight = 0; + let stableHeightPasses = 0; + let steps = 0; + + while (steps < maxScrollSteps) { + const scrollHeight = scrollingElement.scrollHeight; + if (scrollHeight === previousHeight) stableHeightPasses += 1; + else stableHeightPasses = 0; + previousHeight = scrollHeight; + + const nextY = Math.min(window.scrollY + step, scrollHeight - window.innerHeight); + window.scrollTo(0, nextY); + steps += 1; + await delay(220); + + if (nextY >= scrollHeight - window.innerHeight && stableHeightPasses >= 2) { + break; + } + } + + window.scrollTo(0, 0); + await delay(500); + return { steps, scrollHeight: scrollingElement.scrollHeight }; + }, { maxScrollSteps: job.maxScrollSteps }); + + return result; +} + +async function getVisualState(page) { + return page.evaluate(() => { + const scrollingElement = document.scrollingElement || document.documentElement; + const images = Array.from(document.images); + return { + scrollHeight: scrollingElement.scrollHeight, + clientHeight: scrollingElement.clientHeight, + bodyHeight: document.body?.scrollHeight || 0, + imageTotal: images.length, + imageComplete: images.filter((image) => image.complete).length, + fontStatus: document.fonts?.status || 'unsupported', + nodeCount: document.querySelectorAll('*').length, + }; + }); +} + +function visualStateKey(state) { + return [ + state.scrollHeight, + state.bodyHeight, + state.imageTotal, + state.imageComplete, + state.fontStatus, + state.nodeCount, + ].join('|'); +} + +async function waitForVisualStable(page, timeoutMs) { + const startedAt = Date.now(); + let lastKey = ''; + let lastState = null; + let stablePasses = 0; + + while (Date.now() - startedAt < timeoutMs) { + const state = await getVisualState(page); + const key = visualStateKey(state); + const assetsReady = state.imageTotal === state.imageComplete + && (state.fontStatus === 'loaded' || state.fontStatus === 'unsupported'); + + if (key === lastKey && assetsReady) { + stablePasses += 1; + } else { + stablePasses = 0; + } + + lastKey = key; + lastState = state; + if (stablePasses >= 2) { + return { stable: true, elapsedMs: Date.now() - startedAt, state }; + } + await page.waitForTimeout(400); + } + + return { stable: false, elapsedMs: Date.now() - startedAt, state: lastState }; +} + +async function captureJob(playwright, job) { + fs.mkdirSync(path.dirname(job.outputPath), { recursive: true }); + fs.mkdirSync(job.reportDir, { recursive: true }); + + const { browser, context, page, ownsContext } = await openPage(playwright, job); + const startedAt = Date.now(); + let warmup = null; + let stability = null; + + try { + await page.goto(job.url, { waitUntil: job.waitUntil, timeout: job.timeoutMs }); + await injectCaptureCss(page, job); + await waitForSelectors(page, job.waitForSelectors, job.timeoutMs); + await dismissOverlays(page, job.dismissSelectors); + if (job.waitAfterLoadMs > 0) { + await page.waitForTimeout(job.waitAfterLoadMs); + } + await waitForFontsAndImages(page, job.settleTimeoutMs); + if (job.scrollWarmup) { + warmup = await scrollWarmup(page, job); + await waitForFontsAndImages(page, job.settleTimeoutMs); + } + stability = await waitForVisualStable(page, job.settleTimeoutMs); + await injectCaptureCss(page, job); + + const preScreenshotState = await getVisualState(page); + const screenshotPlan = resolveScreenshotPlan(job); + const screenshotBox = resolveScreenshotBox(job, preScreenshotState); + fs.mkdirSync(path.dirname(screenshotPlan.screenshotPath), { recursive: true }); + + const screenshotOptions = { + path: screenshotPlan.screenshotPath, + fullPage: screenshotBox.fullPage, + type: screenshotPlan.playwrightType, + animations: 'disabled', + caret: 'hide', + timeout: job.timeoutMs, + }; + if (screenshotBox.viewport) { + await page.setViewportSize(screenshotBox.viewport); + await page.waitForTimeout(250); + } + if (screenshotPlan.playwrightType === 'jpeg') { + screenshotOptions.quality = job.quality; + } + + await page.screenshot(screenshotOptions); + const converter = screenshotPlan.needsConversion + ? convertToWebp(screenshotPlan.screenshotPath, screenshotPlan.outputPath, job.quality) + : null; + const widthNormalization = normalizeImageWidth(screenshotPlan.outputPath, job); + + const finalState = await getVisualState(page); + const metadata = { + theme: job.theme, + requestedUrl: job.url, + finalUrl: page.url(), + title: await page.title(), + outputPath: job.outputPath, + viewport: job.viewport, + format: job.format, + quality: job.format === 'png' ? undefined : job.quality, + hideScrollbar: job.hideScrollbar, + scrollWarmup: job.scrollWarmup, + screenshotPlan, + screenshotBox, + converter, + widthNormalization, + warmup, + stability, + finalState, + durationMs: Date.now() - startedAt, + fileSize: fs.statSync(job.outputPath).size, + capturedAt: new Date().toISOString(), + auth: { + connectCdp: Boolean(job.connectCdp), + storageState: Boolean(job.storageState), + cookieFile: Boolean(job.cookieFile), + httpCredentials: Boolean(job.httpUser && job.httpPass), + headers: Object.keys(job.headers || {}), + }, + }; + + fs.writeFileSync(job.reportPath, JSON.stringify(metadata, null, 2)); + return metadata; + } finally { + await page.close().catch(() => {}); + if (!job.connectCdp && ownsContext) { + await context.close().catch(() => {}); + } + if (!job.connectCdp) { + await browser.close().catch(() => {}); + } + } +} + +function showHelp() { + console.log(` +Capture a long full-page official homepage screenshot for a theme. + +Usage: + npm run capture:theme -- --theme linear + npm run capture:theme -- --theme linear --url https://linear.app + npm run capture:theme -- --config theme-capture.json --all + +Options: + --theme NAME Theme directory name under src/themes + --url URL Homepage URL. Defaults to theme.json source.websiteUrl + --config FILE JSON config with defaults and themes + --all Capture every theme from config + --output PATH Output file for one theme, or output directory for batch + --viewport WxH Viewport before full-page capture. Default: 1440x1200 + --hide-scrollbar Hide scrollbars before screenshot. Default + --show-scrollbar Keep scrollbars visible + --scroll-warmup Scroll down and back up to trigger lazy loading. Default + --no-scroll-warmup Disable lazy-load warmup + --max-screenshot-height N Cap WebP output height. Default: 16383 + --wait-for-selector SEL Wait for selector before capture. Repeatable + --dismiss-selector SEL Click optional overlay close/accept selector. Repeatable + --remove-selector SEL Hide noisy selector before capture. Repeatable + --unstick-selector SEL Make sticky selector static before capture. Repeatable + --connect-cdp URL Reuse a logged-in Chrome, for example http://localhost:9222 + --browser-executable PATH Use a specific Chrome/Chromium executable + --storage-state FILE Playwright storageState JSON + --cookie-file FILE Playwright cookies JSON or { "cookies": [] } + --header Name=Value Extra HTTP header. Repeatable + --http-user USER Basic auth username + --http-pass PASS Basic auth password + --dry-run Print resolved jobs without opening a browser +`); +} + +async function runCli() { + const args = parseCaptureArgs(process.argv); + if (args.help) { + showHelp(); + return; + } + + const jobs = resolveCaptureJobs(args); + if (args.dryRun) { + console.log(JSON.stringify(jobs, null, 2)); + return; + } + + const playwright = await loadPlaywright(); + for (const job of jobs) { + console.log(`[theme-capture] ${job.theme} ${job.url}`); + const metadata = await captureJob(playwright, job); + console.log(`[theme-capture] wrote ${metadata.outputPath}`); + console.log(`[theme-capture] report ${job.reportPath}`); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + runCli().catch((error) => { + console.error(`[theme-capture] ${error.stack || error.message || error}`); + process.exitCode = 1; + }); +} diff --git a/scripts/capture-theme-homepage.test.mjs b/scripts/capture-theme-homepage.test.mjs new file mode 100644 index 0000000..44789bc --- /dev/null +++ b/scripts/capture-theme-homepage.test.mjs @@ -0,0 +1,272 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + findWorkspaceRoot, + normalizeViewport, + parseCaptureArgs, + resolveChromiumLaunchOptions, + resolveCaptureJobs, + resolveScreenshotBox, + resolveScreenshotPlan, + resolveWidthNormalization, +} from './capture-theme-homepage.mjs'; + +function makeFixture() { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'theme-capture-')); + const appRoot = path.join(workspaceRoot, 'apps', 'make-project'); + const themeRoot = path.join(appRoot, 'src', 'themes', 'linear'); + fs.mkdirSync(themeRoot, { recursive: true }); + fs.writeFileSync(path.join(workspaceRoot, 'pnpm-workspace.yaml'), 'packages: []\n'); + fs.writeFileSync( + path.join(themeRoot, 'theme.json'), + JSON.stringify({ + source: { + websiteUrl: 'https://linear.app/', + originalDetailUrl: 'https://getdesign.md/linear.app/design-md', + }, + }, null, 2), + ); + return { workspaceRoot, appRoot }; +} + +describe('capture-theme-homepage CLI planning', () => { + it('parses screenshot options needed for stable long homepage captures', () => { + const args = parseCaptureArgs([ + 'node', + 'capture-theme-homepage.mjs', + '--', + '--theme', + 'linear', + '--url', + 'https://linear.app', + '--viewport', + '1440x1200', + '--wait-for-selector', + 'main', + '--dismiss-selector', + 'button:has-text("Accept")', + '--remove-selector', + '.newsletter-modal', + '--header', + 'Authorization=Bearer token', + '--connect-cdp', + 'http://localhost:9222', + '--storage-state', + '.local/auth/linear.json', + '--hide-scrollbar', + '--scroll-warmup', + '--quality', + '92', + ]); + + expect(args.theme).toBe('linear'); + expect(args.url).toBe('https://linear.app'); + expect(args.viewport).toEqual({ width: 1440, height: 1200 }); + expect(args.waitForSelectors).toEqual(['main']); + expect(args.dismissSelectors).toEqual(['button:has-text("Accept")']); + expect(args.removeSelectors).toEqual(['.newsletter-modal']); + expect(args.headers).toEqual({ Authorization: 'Bearer token' }); + expect(args.connectCdp).toBe('http://localhost:9222'); + expect(args.storageState).toBe('.local/auth/linear.json'); + expect(args.hideScrollbar).toBe(true); + expect(args.scrollWarmup).toBe(true); + expect(args.quality).toBe(92); + }); + + it('resolves a single theme from theme.json metadata into the official homepage asset path', () => { + const { workspaceRoot, appRoot } = makeFixture(); + const args = parseCaptureArgs(['node', 'capture-theme-homepage.mjs', '--theme', 'linear']); + + const jobs = resolveCaptureJobs(args, { appRoot, workspaceRoot }); + + expect(jobs).toHaveLength(1); + expect(jobs[0].theme).toBe('linear'); + expect(jobs[0].url).toBe('https://linear.app/'); + expect(jobs[0].outputPath).toBe(path.join(appRoot, 'src/themes/linear/assets/official-homepage.webp')); + expect(jobs[0].reportPath).toBe(path.join(workspaceRoot, '.local/theme-captures/linear/meta.json')); + expect(jobs[0].viewport).toEqual({ width: 1440, height: 1200 }); + expect(jobs[0].waitUntil).toBe('load'); + expect(jobs[0].hideScrollbar).toBe(true); + expect(jobs[0].scrollWarmup).toBe(true); + expect(jobs[0]).not.toHaveProperty('dryRun'); + expect(jobs[0]).not.toHaveProperty('help'); + }); + + it('merges config defaults, per-theme settings, and CLI overrides for batch capture', () => { + const { workspaceRoot, appRoot } = makeFixture(); + const configPath = path.join(workspaceRoot, 'theme-capture.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + defaults: { + viewport: '1920x1080', + waitForSelectors: ['main'], + removeSelectors: ['.chat-widget'], + headers: { 'X-Capture': 'theme' }, + }, + themes: { + linear: { + url: 'https://linear.app/custom', + dismissSelectors: ['button:has-text("Accept all")'], + waitAfterLoadMs: 2500, + }, + stripe: 'https://stripe.com', + }, + }, null, 2), + ); + + const args = parseCaptureArgs([ + 'node', + 'capture-theme-homepage.mjs', + '--config', + configPath, + '--theme', + 'linear', + '--viewport', + '1366x900', + '--header', + 'Authorization=Bearer local', + ]); + + const jobs = resolveCaptureJobs(args, { appRoot, workspaceRoot }); + + expect(jobs).toHaveLength(1); + expect(jobs[0]).toMatchObject({ + theme: 'linear', + url: 'https://linear.app/custom', + viewport: { width: 1366, height: 900 }, + waitForSelectors: ['main'], + removeSelectors: ['.chat-widget'], + dismissSelectors: ['button:has-text("Accept all")'], + waitAfterLoadMs: 2500, + headers: { + 'X-Capture': 'theme', + Authorization: 'Bearer local', + }, + }); + }); + + it('expands all config themes when --all is requested', () => { + const { workspaceRoot, appRoot } = makeFixture(); + const configPath = path.join(workspaceRoot, 'theme-capture.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + themes: [ + { theme: 'linear', url: 'https://linear.app' }, + { theme: 'stripe', url: 'https://stripe.com' }, + ], + }), + ); + + const args = parseCaptureArgs(['node', 'capture-theme-homepage.mjs', '--config', configPath, '--all']); + + expect(resolveCaptureJobs(args, { appRoot, workspaceRoot }).map((job) => job.theme)).toEqual([ + 'linear', + 'stripe', + ]); + }); + + it('finds the workspace root and rejects invalid viewport strings', () => { + const { workspaceRoot, appRoot } = makeFixture(); + + expect(findWorkspaceRoot(appRoot)).toBe(workspaceRoot); + expect(() => normalizeViewport('wide')).toThrow('Invalid viewport'); + }); + + it('builds launch options with an explicit or discovered Chrome executable fallback', () => { + expect(resolveChromiumLaunchOptions({ + headless: true, + browserExecutable: '/tmp/Test Chrome', + }).executablePath).toBe('/tmp/Test Chrome'); + + const discovered = resolveChromiumLaunchOptions( + { headless: false }, + { + candidatePaths: ['/missing/chrome', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'], + exists: (candidate) => candidate.includes('Google Chrome'), + }, + ); + + expect(discovered.headless).toBe(false); + expect(discovered.executablePath).toBe('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'); + expect(discovered.args).toContain('--disable-blink-features=AutomationControlled'); + }); + + it('captures webp outputs through a temporary png screenshot plan', () => { + const plan = resolveScreenshotPlan({ + theme: 'linear', + format: 'webp', + outputPath: '/tmp/linear/official-homepage.webp', + reportDir: '/tmp/.local/theme-captures/linear', + }); + + expect(plan).toEqual({ + playwrightType: 'png', + screenshotPath: '/tmp/.local/theme-captures/linear/official-homepage-source.png', + outputPath: '/tmp/linear/official-homepage.webp', + needsConversion: true, + }); + }); + + it('clips very tall webp screenshots to the encoder-safe height', () => { + expect(resolveScreenshotBox({ + format: 'webp', + viewport: { width: 1440, height: 1200 }, + maxScreenshotHeight: 16383, + }, { + scrollHeight: 24000, + })).toEqual({ + fullPage: false, + viewport: { width: 1440, height: 16383 }, + clipped: true, + sourceHeight: 24000, + outputHeight: 16383, + }); + + expect(resolveScreenshotBox({ + format: 'webp', + viewport: { width: 1440, height: 1200 }, + maxScreenshotHeight: 16383, + }, { + scrollHeight: 8000, + })).toEqual({ + fullPage: true, + clipped: false, + sourceHeight: 8000, + outputHeight: 8000, + }); + }); + + it('normalizes horizontally overflowing screenshots back to the viewport width', () => { + expect(resolveWidthNormalization({ + viewport: { width: 1440, height: 1200 }, + }, { + width: 3870, + height: 6517, + })).toEqual({ + needed: true, + width: 1440, + height: 6517, + x: 0, + y: 0, + }); + + expect(resolveWidthNormalization({ + viewport: { width: 1440, height: 1200 }, + }, { + width: 1440, + height: 6517, + })).toEqual({ + needed: false, + width: 1440, + height: 6517, + x: 0, + y: 0, + }); + }); +}); diff --git a/scripts/capture-theme-source.mjs b/scripts/capture-theme-source.mjs new file mode 100644 index 0000000..0928068 --- /dev/null +++ b/scripts/capture-theme-source.mjs @@ -0,0 +1,824 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + findWorkspaceRoot, + normalizeViewport, + resolveChromiumLaunchOptions, +} from './capture-theme-homepage.mjs'; + +const PLAYWRIGHT_CACHE_DIR = path.join(os.homedir(), '.cache', 'axure-extractor'); + +const DEFAULT_SOURCE_CAPTURE_OPTIONS = { + viewport: { width: 1440, height: 900 }, + responsiveViewports: [ + { name: 'desktop', width: 1440, height: 900 }, + { name: 'tablet', width: 768, height: 1024 }, + { name: 'mobile', width: 390, height: 844 }, + ], + deviceScaleFactor: 1, + waitUntil: 'domcontentloaded', + timeoutMs: 60000, + waitAfterLoadMs: 3000, + settleTimeoutMs: 8000, + scrollWarmup: true, + maxScrollSteps: 60, + headless: true, + responsive: true, + screenshot: true, + tokens: true, + hideScrollbar: true, +}; + +function readRequiredValue(argv, index, flag) { + const value = argv[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`${flag} requires a value`); + } + return value; +} + +function parseInteger(value, name) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + throw new Error(`Invalid ${name}: ${value}`); + } + return parsed; +} + +function normalizeSelectorList(value) { + if (!value) return []; + if (Array.isArray(value)) return value.filter(Boolean).map(String); + return [String(value)]; +} + +function parseHeader(value) { + const separatorIndex = value.includes('=') ? value.indexOf('=') : value.indexOf(':'); + if (separatorIndex <= 0) { + throw new Error(`Invalid header, expected Name=Value: ${value}`); + } + const name = value.slice(0, separatorIndex).trim(); + const headerValue = value.slice(separatorIndex + 1).trim(); + if (!name) throw new Error(`Invalid header, expected Name=Value: ${value}`); + return [name, headerValue]; +} + +function parseNamedViewport(value, fallbackName) { + const [maybeName, maybeViewport] = value.includes(':') + ? value.split(':', 2) + : [fallbackName, value]; + const viewport = normalizeViewport(maybeViewport); + return { name: maybeName || fallbackName, ...viewport }; +} + +export function parseResponsiveViewports(value) { + if (!value) return DEFAULT_SOURCE_CAPTURE_OPTIONS.responsiveViewports; + return String(value) + .split(',') + .map((item, index) => parseNamedViewport(item.trim(), `viewport-${index + 1}`)); +} + +export function inferThemeFromUrl(url) { + try { + return new URL(url).hostname.replace(/^www\./, '').split('.')[0] || 'homepage'; + } catch { + return 'homepage'; + } +} + +export function parseSourceCaptureArgs(argv = process.argv) { + const args = { + headers: {}, + waitForSelectors: [], + dismissSelectors: [], + removeSelectors: [], + help: false, + dryRun: false, + }; + + const rawArgs = argv.slice(2); + for (let i = 0; i < rawArgs.length; i += 1) { + const flag = rawArgs[i]; + + if (flag === '--') { + continue; + } else if (flag === '--help' || flag === '-h') { + args.help = true; + } else if (flag === '--dry-run') { + args.dryRun = true; + } else if (flag === '--theme') { + args.theme = readRequiredValue(rawArgs, i, flag); + i += 1; + } else if (flag === '--url') { + args.url = readRequiredValue(rawArgs, i, flag); + i += 1; + } else if (flag === '--output' || flag === '-o') { + args.output = readRequiredValue(rawArgs, i, flag); + i += 1; + } else if (flag === '--viewport') { + args.viewport = normalizeViewport(readRequiredValue(rawArgs, i, flag)); + i += 1; + } else if (flag === '--responsive-viewports') { + args.responsiveViewports = parseResponsiveViewports(readRequiredValue(rawArgs, i, flag)); + i += 1; + } else if (flag === '--wait-until') { + args.waitUntil = readRequiredValue(rawArgs, i, flag); + i += 1; + } else if (flag === '--timeout') { + args.timeoutMs = parseInteger(readRequiredValue(rawArgs, i, flag), flag); + i += 1; + } else if (flag === '--wait') { + args.waitAfterLoadMs = parseInteger(readRequiredValue(rawArgs, i, flag), flag); + i += 1; + } else if (flag === '--settle-timeout') { + args.settleTimeoutMs = parseInteger(readRequiredValue(rawArgs, i, flag), flag); + i += 1; + } else if (flag === '--max-scroll-steps') { + args.maxScrollSteps = parseInteger(readRequiredValue(rawArgs, i, flag), flag); + i += 1; + } else if (flag === '--selector') { + args.selector = readRequiredValue(rawArgs, i, flag); + i += 1; + } else if (flag === '--wait-for-selector') { + args.waitForSelectors.push(readRequiredValue(rawArgs, i, flag)); + i += 1; + } else if (flag === '--dismiss-selector') { + args.dismissSelectors.push(readRequiredValue(rawArgs, i, flag)); + i += 1; + } else if (flag === '--remove-selector') { + args.removeSelectors.push(readRequiredValue(rawArgs, i, flag)); + i += 1; + } else if (flag === '--header') { + const [name, value] = parseHeader(readRequiredValue(rawArgs, i, flag)); + args.headers[name] = value; + i += 1; + } else if (flag === '--connect-cdp') { + args.connectCdp = readRequiredValue(rawArgs, i, flag); + i += 1; + } else if (flag === '--browser-executable') { + args.browserExecutable = readRequiredValue(rawArgs, i, flag); + i += 1; + } else if (flag === '--storage-state') { + args.storageState = readRequiredValue(rawArgs, i, flag); + i += 1; + } else if (flag === '--headless') { + args.headless = true; + } else if (flag === '--no-headless') { + args.headless = false; + } else if (flag === '--scroll-warmup') { + args.scrollWarmup = true; + } else if (flag === '--no-scroll-warmup') { + args.scrollWarmup = false; + } else if (flag === '--responsive') { + args.responsive = true; + } else if (flag === '--no-responsive') { + args.responsive = false; + } else if (flag === '--screenshot') { + args.screenshot = true; + } else if (flag === '--no-screenshot') { + args.screenshot = false; + } else if (flag === '--tokens') { + args.tokens = true; + } else if (flag === '--no-tokens') { + args.tokens = false; + } else if (flag === '--hide-scrollbar') { + args.hideScrollbar = true; + } else if (flag === '--show-scrollbar') { + args.hideScrollbar = false; + } else if (flag.startsWith('--')) { + throw new Error(`Unknown option: ${flag}`); + } else if (!args.url) { + args.url = flag; + } else if (!args.theme) { + args.theme = flag; + } else { + throw new Error(`Unexpected argument: ${flag}`); + } + } + + return args; +} + +function resolveMaybePath(value, baseDir) { + if (!value) return value; + return path.isAbsolute(value) ? value : path.resolve(baseDir, value); +} + +export function resolveSourceCaptureJob(args, options = {}) { + const appRoot = path.resolve(options.appRoot || process.cwd()); + const workspaceRoot = path.resolve(options.workspaceRoot || findWorkspaceRoot(appRoot)); + const url = args.url; + if (!url) throw new Error('A URL is required. Pass or --url .'); + + const theme = args.theme || inferThemeFromUrl(url); + const outputDir = resolveMaybePath( + args.output || path.join(appRoot, '.local', `theme-capture-${theme}`), + appRoot, + ); + const storageState = resolveMaybePath(args.storageState, appRoot); + + return { + ...DEFAULT_SOURCE_CAPTURE_OPTIONS, + ...Object.fromEntries(Object.entries(args).filter(([, value]) => value !== undefined)), + theme, + url, + appRoot, + workspaceRoot, + outputDir, + storageState, + waitForSelectors: normalizeSelectorList(args.waitForSelectors), + dismissSelectors: normalizeSelectorList(args.dismissSelectors), + removeSelectors: normalizeSelectorList(args.removeSelectors), + headers: args.headers || {}, + }; +} + +async function loadPlaywright() { + try { + return await import('playwright'); + } catch { + const cachedPath = path.join(PLAYWRIGHT_CACHE_DIR, 'node_modules', 'playwright', 'index.mjs'); + if (fs.existsSync(cachedPath)) { + return import(pathToFileURL(cachedPath).href); + } + } + + throw new Error( + 'Playwright is not available. Install dependencies with pnpm install, or run an existing capture tool once to populate the shared cache.', + ); +} + +async function openPage(playwright, job, viewport) { + let browser; + let context; + let page; + let ownsContext = true; + + if (job.connectCdp) { + browser = await playwright.chromium.connectOverCDP(job.connectCdp); + context = browser.contexts()[0] || await browser.newContext(); + ownsContext = browser.contexts()[0] !== context; + page = await context.newPage(); + await page.setViewportSize(viewport); + await context.setExtraHTTPHeaders(job.headers || {}); + } else { + browser = await playwright.chromium.launch(resolveChromiumLaunchOptions(job)); + context = await browser.newContext({ + viewport, + deviceScaleFactor: job.deviceScaleFactor, + storageState: job.storageState && fs.existsSync(job.storageState) ? job.storageState : undefined, + extraHTTPHeaders: job.headers, + ignoreHTTPSErrors: true, + }); + page = await context.newPage(); + } + + return { browser, context, page, ownsContext }; +} + +async function closePageSession(session, job) { + await session.page.close().catch(() => {}); + if (!job.connectCdp && session.ownsContext) { + await session.context.close().catch(() => {}); + } + if (!job.connectCdp) { + await session.browser.close().catch(() => {}); + } +} + +async function injectCaptureCss(page, job) { + const scrollbarCss = job.hideScrollbar + ? ` +html, body { + scrollbar-width: none !important; + -ms-overflow-style: none !important; +} +html::-webkit-scrollbar, +body::-webkit-scrollbar, +*::-webkit-scrollbar { + width: 0 !important; + height: 0 !important; + display: none !important; +} +` + : ''; + const removeCss = (job.removeSelectors || []) + .map((selector) => `${selector} { display: none !important; visibility: hidden !important; }`) + .join('\n'); + + await page.addStyleTag({ + content: ` +${scrollbarCss} +* { + caret-color: transparent !important; +} +${removeCss} +`, + }); +} + +async function dismissOverlays(page, selectors) { + for (const selector of selectors || []) { + try { + await page.locator(selector).first().click({ timeout: 1500 }); + await page.waitForTimeout(300); + } catch { + // Optional dismiss selectors should never fail a source capture. + } + } +} + +async function waitForSelectors(page, selectors, timeoutMs) { + for (const selector of selectors || []) { + await page.waitForSelector(selector, { timeout: timeoutMs, state: 'attached' }); + } +} + +async function waitForFontsAndImages(page, timeoutMs) { + await page.evaluate(async (timeout) => { + const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const withTimeout = (promise) => Promise.race([promise, delay(timeout)]); + + if (document.fonts?.ready) { + await withTimeout(document.fonts.ready); + } + + const pendingImages = Array.from(document.images).filter((image) => !image.complete); + await withTimeout(Promise.all(pendingImages.map((image) => new Promise((resolve) => { + image.addEventListener('load', resolve, { once: true }); + image.addEventListener('error', resolve, { once: true }); + })))); + }, Math.min(timeoutMs, 8000)); +} + +async function scrollWarmup(page, job) { + return page.evaluate(async ({ maxScrollSteps }) => { + const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const scrollingElement = document.scrollingElement || document.documentElement; + const step = Math.max(600, Math.floor(window.innerHeight * 0.85)); + let previousHeight = 0; + let stableHeightPasses = 0; + let steps = 0; + + while (steps < maxScrollSteps) { + const scrollHeight = scrollingElement.scrollHeight; + if (scrollHeight === previousHeight) stableHeightPasses += 1; + else stableHeightPasses = 0; + previousHeight = scrollHeight; + + const nextY = Math.min(window.scrollY + step, scrollHeight - window.innerHeight); + window.scrollTo(0, nextY); + steps += 1; + await delay(220); + + if (nextY >= scrollHeight - window.innerHeight && stableHeightPasses >= 2) { + break; + } + } + + window.scrollTo(0, 0); + await delay(500); + return { steps, scrollHeight: scrollingElement.scrollHeight }; + }, { maxScrollSteps: job.maxScrollSteps }); +} + +async function preparePage(page, job) { + await page.goto(job.url, { waitUntil: job.waitUntil, timeout: job.timeoutMs }); + await injectCaptureCss(page, job); + await waitForSelectors(page, job.waitForSelectors, job.timeoutMs); + await dismissOverlays(page, job.dismissSelectors); + if (job.waitAfterLoadMs > 0) { + await page.waitForTimeout(job.waitAfterLoadMs); + } + await waitForFontsAndImages(page, job.settleTimeoutMs); + const warmup = job.scrollWarmup ? await scrollWarmup(page, job) : null; + await waitForFontsAndImages(page, job.settleTimeoutMs); + await injectCaptureCss(page, job); + return warmup; +} + +export function summarizeTheme(theme) { + const summary = { + colors: theme.colors || {}, + typography: theme.typography || {}, + spacing: theme.spacing || [], + radius: theme.radius || [], + lineWidth: theme.lineWidth || [], + shadow: theme.shadow || {}, + transitions: theme.transitions || [], + animations: theme.animations || [], + cssVariables: theme.cssVariables || {}, + }; + return summary; +} + +async function extractThemeTokens(page, job) { + return page.evaluate((selector) => { + const makeBucket = () => new Map(); + const add = (bucket, value, tag) => { + if (!value) return; + const trimmed = String(value).trim(); + if (!trimmed) return; + if (!bucket.has(trimmed)) bucket.set(trimmed, { count: 0, tags: new Set() }); + const item = bucket.get(trimmed); + item.count += 1; + if (tag) item.tags.add(tag); + }; + const isZeroish = (value) => { + if (!value) return false; + const trimmed = value.trim(); + if (trimmed === '0' || trimmed === '0px' || trimmed === '0%') return true; + const parts = trimmed.split(/\s+/); + return parts.length > 1 && parts.every((part) => part === '0' || part === '0px' || part === '0%'); + }; + const isTransparentColor = (value) => { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return normalized === 'transparent' + || normalized === 'rgba(0, 0, 0, 0)' + || normalized === 'rgba(0,0,0,0)'; + }; + const normalizeTags = (tags) => (tags.has('body') ? ['body'] : [...tags]); + const sortTopInPage = (bucket, limit = 10) => + [...bucket.entries()] + .sort((a, b) => b[1].count - a[1].count) + .slice(0, limit) + .map(([value, obj]) => ({ value, count: obj.count, tags: normalizeTags(obj.tags) })); + const sortTextStyles = (bucket, limit = 10) => + [...bucket.entries()] + .sort((a, b) => b[1].count - a[1].count) + .slice(0, limit) + .map(([value, obj]) => { + const [size, lineHeight, weight, letterSpacing] = value.split('||'); + return { size, lineHeight, weight, letterSpacing, count: obj.count, tags: normalizeTags(obj.tags) }; + }); + + const root = selector ? document.querySelector(selector) : document.documentElement; + if (!root) throw new Error(`Selector "${selector}" not found`); + const walkRoot = root.tagName === 'HTML' ? document.body : root; + const walker = document.createTreeWalker(walkRoot, NodeFilter.SHOW_ELEMENT); + + const spacingBucket = makeBucket(); + const colorBuckets = { background: makeBucket(), text: makeBucket(), border: makeBucket() }; + const typographyBuckets = { family: makeBucket(), textStyle: makeBucket() }; + const radiusBucket = makeBucket(); + const lineWidthBucket = makeBucket(); + const shadowBuckets = { box: makeBucket(), text: makeBucket() }; + const animationBucket = makeBucket(); + const transitionBucket = makeBucket(); + const bgImageBucket = makeBucket(); + let el = walker.currentNode; + + while (el) { + const tag = el.tagName?.toLowerCase() || ''; + const rect = el.getBoundingClientRect(); + const style = getComputedStyle(el); + const isVisible = rect.width > 0 + && rect.height > 0 + && style.display !== 'none' + && style.visibility !== 'hidden' + && Number(style.opacity || 1) !== 0; + + if (isVisible) { + if (style.backgroundColor && !isTransparentColor(style.backgroundColor)) { + add(colorBuckets.background, style.backgroundColor, tag); + } + if (style.color && !isTransparentColor(style.color)) { + add(colorBuckets.text, style.color, tag); + } + [style.borderColor, style.borderTopColor, style.borderRightColor, style.borderBottomColor, style.borderLeftColor] + .forEach((color) => { + if (color && !isTransparentColor(color)) add(colorBuckets.border, color, tag); + }); + add(typographyBuckets.family, style.fontFamily, tag); + if (style.fontSize && style.fontWeight && style.lineHeight) { + add(typographyBuckets.textStyle, `${style.fontSize}||${style.lineHeight}||${style.fontWeight}||${style.letterSpacing}`, tag); + } + const addSpacingParts = (value) => { + if (!value) return; + value.trim().split(/\s+/).filter(Boolean).forEach((part) => { + if (!isZeroish(part)) add(spacingBucket, part, tag); + }); + }; + addSpacingParts(style.margin); + addSpacingParts(style.padding); + if (style.gap && style.gap !== 'normal') addSpacingParts(style.gap); + if (style.borderRadius && !isZeroish(style.borderRadius)) add(radiusBucket, style.borderRadius, tag); + ['borderWidth', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth'].forEach((widthProp) => { + const value = style[widthProp]; + if (value && !isZeroish(value)) add(lineWidthBucket, value, tag); + }); + if (style.boxShadow && style.boxShadow !== 'none') add(shadowBuckets.box, style.boxShadow, tag); + if (style.textShadow && style.textShadow !== 'none') add(shadowBuckets.text, style.textShadow, tag); + if (style.animationName && style.animationName !== 'none') { + add(animationBucket, `${style.animationName}|${style.animationDuration}|${style.animationTimingFunction}`, tag); + } + if (style.transition && style.transition !== 'none' && !style.transition.startsWith('all 0s')) { + add(transitionBucket, style.transition, tag); + } + if (style.backgroundImage && style.backgroundImage !== 'none') add(bgImageBucket, style.backgroundImage, tag); + } + + el = walker.nextNode(); + } + + const cssVariables = {}; + const rootStyle = getComputedStyle(document.documentElement); + for (const sheet of [...document.styleSheets]) { + try { + for (const rule of [...(sheet.cssRules || [])]) { + if (rule instanceof CSSStyleRule && rule.selectorText === ':root') { + for (const prop of [...rule.style]) { + if (prop.startsWith('--')) cssVariables[prop] = rootStyle.getPropertyValue(prop).trim(); + } + } + } + } catch { + // Ignore cross-origin stylesheets. + } + } + + const images = [...document.querySelectorAll('img')].map((img) => { + const imgStyle = getComputedStyle(img); + return { + src: img.currentSrc || img.src, + alt: img.alt, + width: img.naturalWidth || null, + height: img.naturalHeight || null, + position: imgStyle.position, + zIndex: imgStyle.zIndex, + siblingImgCount: img.parentElement ? img.parentElement.querySelectorAll('img').length : 0, + }; + }); + + return { + colors: { + background: sortTopInPage(colorBuckets.background, 10), + text: sortTopInPage(colorBuckets.text, 10), + border: sortTopInPage(colorBuckets.border, 8), + }, + typography: { + families: sortTopInPage(typographyBuckets.family, 8), + textStyles: sortTextStyles(typographyBuckets.textStyle, 12), + }, + spacing: sortTopInPage(spacingBucket, 18), + radius: sortTopInPage(radiusBucket, 8), + lineWidth: sortTopInPage(lineWidthBucket, 6), + shadow: { + box: sortTopInPage(shadowBuckets.box, 8), + text: sortTopInPage(shadowBuckets.text, 4), + }, + animations: sortTopInPage(animationBucket, 8), + transitions: sortTopInPage(transitionBucket, 8), + cssVariables, + assets: { + backgroundImages: sortTopInPage(bgImageBucket, 12), + images, + svgCount: document.querySelectorAll('svg').length, + }, + }; + }, job.selector || null); +} + +async function extractSamples(page) { + return page.evaluate(() => { + const rgbaToHex = (value) => { + const match = String(value || '').match(/rgba?\(([^)]+)\)/); + if (!match) return value; + const parts = match[1].split(',').map((part) => part.trim()); + const [r, g, b] = parts.slice(0, 3).map(Number); + const alpha = parts[3] === undefined ? 1 : Number(parts[3]); + const hex = [r, g, b] + .map((num) => Math.max(0, Math.min(255, Math.round(num))).toString(16).padStart(2, '0')) + .join(''); + return alpha === 1 ? `#${hex}` : `#${hex}${Math.round(alpha * 255).toString(16).padStart(2, '0')}`; + }; + const sampleSelectors = ['header', 'nav', 'h1', 'h2', 'h3', 'button', 'a[href]', 'input', 'textarea', 'main section', 'footer']; + return sampleSelectors.flatMap((selector) => [...document.querySelectorAll(selector)].slice(0, 10).map((el) => { + const style = getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return { + selector, + tag: el.tagName.toLowerCase(), + text: (el.innerText || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 180), + color: rgbaToHex(style.color), + background: rgbaToHex(style.backgroundColor), + border: `${style.borderTopWidth} ${style.borderTopStyle} ${rgbaToHex(style.borderTopColor)}`, + radius: style.borderTopLeftRadius, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + fontWeight: style.fontWeight, + letterSpacing: style.letterSpacing, + padding: `${style.paddingTop} ${style.paddingRight} ${style.paddingBottom} ${style.paddingLeft}`, + margin: `${style.marginTop} ${style.marginRight} ${style.marginBottom} ${style.marginLeft}`, + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + })); + }); +} + +async function captureViewport(playwright, job, viewport, outputPath, { includeTokens = false } = {}) { + const session = await openPage(playwright, job, { width: viewport.width, height: viewport.height }); + const startedAt = Date.now(); + try { + const warmup = await preparePage(session.page, job); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + if (job.screenshot) { + await session.page.screenshot({ + path: outputPath, + fullPage: true, + type: 'png', + animations: 'disabled', + caret: 'hide', + timeout: job.timeoutMs, + }); + } + + const state = await session.page.evaluate(() => { + const scrollingElement = document.scrollingElement || document.documentElement; + return { + url: window.location.href, + title: document.title, + viewport: { width: window.innerWidth, height: window.innerHeight }, + scrollHeight: scrollingElement.scrollHeight, + nodeCount: document.querySelectorAll('*').length, + }; + }); + + let theme = null; + let samples = []; + if (includeTokens && job.tokens) { + theme = await extractThemeTokens(session.page, job); + samples = await extractSamples(session.page); + } + + return { + ...state, + name: viewport.name, + screenshot: job.screenshot ? outputPath : null, + warmup, + durationMs: Date.now() - startedAt, + theme, + samples, + }; + } finally { + await closePageSession(session, job); + } +} + +async function captureSourceJob(playwright, job) { + fs.mkdirSync(job.outputDir, { recursive: true }); + const responsiveDir = path.join(job.outputDir, 'responsive'); + const viewport = { name: 'desktop', ...job.viewport }; + const mainScreenshotPath = path.join(job.outputDir, 'screenshot.png'); + + const main = await captureViewport(playwright, job, viewport, mainScreenshotPath, { includeTokens: true }); + const responsive = {}; + + if (job.responsive) { + fs.mkdirSync(responsiveDir, { recursive: true }); + for (const item of job.responsiveViewports) { + const screenshotPath = path.join(responsiveDir, `${item.name}.png`); + responsive[item.name] = await captureViewport(playwright, job, item, screenshotPath, { + includeTokens: false, + }); + } + } + + const metadata = { + tool: 'capture-theme-source', + theme: job.theme, + requestedUrl: job.url, + finalUrl: main.url, + title: main.title, + viewport: job.viewport, + responsiveViewports: job.responsiveViewports, + outputDir: job.outputDir, + files: { + screenshot: job.screenshot ? 'screenshot.png' : null, + responsive: job.responsive ? Object.fromEntries(Object.keys(responsive).map((name) => [name, `responsive/${name}.png`])) : {}, + theme: job.tokens ? 'theme.json' : null, + computedTokens: job.tokens ? 'computed-tokens.json' : null, + }, + page: { + scrollHeight: main.scrollHeight, + nodeCount: main.nodeCount, + }, + responsive: Object.fromEntries(Object.entries(responsive).map(([name, value]) => [ + name, + { + url: value.url, + title: value.title, + viewport: value.viewport, + scrollHeight: value.scrollHeight, + nodeCount: value.nodeCount, + screenshot: path.relative(job.outputDir, value.screenshot), + }, + ])), + captureOptions: { + waitUntil: job.waitUntil, + waitAfterLoadMs: job.waitAfterLoadMs, + scrollWarmup: job.scrollWarmup, + selector: job.selector || null, + waitForSelectors: job.waitForSelectors, + dismissSelectors: job.dismissSelectors, + removeSelectors: job.removeSelectors, + connectCdp: Boolean(job.connectCdp), + browserExecutable: Boolean(job.browserExecutable), + storageState: Boolean(job.storageState), + }, + capturedAt: new Date().toISOString(), + }; + + if (job.tokens && main.theme) { + fs.writeFileSync(path.join(job.outputDir, 'theme.json'), JSON.stringify(main.theme, null, 2)); + fs.writeFileSync(path.join(job.outputDir, 'computed-tokens.json'), JSON.stringify({ + page: { + url: main.url, + title: main.title, + viewport: main.viewport, + scrollHeight: main.scrollHeight, + nodeCount: main.nodeCount, + }, + responsive: metadata.responsive, + tokens: summarizeTheme(main.theme), + samples: main.samples, + }, null, 2)); + } + + fs.writeFileSync(path.join(job.outputDir, 'meta.json'), JSON.stringify(metadata, null, 2)); + return metadata; +} + +function showHelp() { + console.log(` +Capture source evidence for building an Axhub Make theme. + +Usage: + pnpm exec node scripts/capture-theme-source.mjs https://example.com --theme example + pnpm exec node scripts/capture-theme-source.mjs --url https://example.com --theme example -o .local/theme-capture-example + +Outputs: + .local/theme-capture-/ + screenshot.png + responsive/desktop.png + responsive/tablet.png + responsive/mobile.png + theme.json + computed-tokens.json + meta.json + +Options: + --theme NAME Theme key used for the default output path + --url URL Source URL + -o, --output DIR Output directory. Default: .local/theme-capture- + --viewport WxH Desktop viewport. Default: 1440x900 + --responsive-viewports LIST Comma list: desktop:1440x900,tablet:768x1024,mobile:390x844 + --selector SEL Scope token extraction to a selector + --wait MS Extra wait after load. Default: 3000 + --wait-until STATE load, domcontentloaded, or networkidle. Default: domcontentloaded + --wait-for-selector SEL Wait for selector before capture. Repeatable + --dismiss-selector SEL Click optional overlay accept/close selector. Repeatable + --remove-selector SEL Hide noisy selector before capture. Repeatable + --connect-cdp URL Reuse a running Chrome, for example http://localhost:9222 + --browser-executable PATH Use a specific Chrome/Chromium executable + --storage-state FILE Playwright storageState JSON + --no-responsive Skip responsive screenshots + --no-screenshot Skip screenshot files + --no-tokens Skip theme.json and computed-tokens.json + --dry-run Print resolved job without opening a browser +`); +} + +async function runCli() { + const args = parseSourceCaptureArgs(process.argv); + if (args.help) { + showHelp(); + return; + } + + const job = resolveSourceCaptureJob(args); + if (args.dryRun) { + console.log(JSON.stringify(job, null, 2)); + return; + } + + const playwright = await loadPlaywright(); + console.log(`[theme-source] ${job.theme} ${job.url}`); + const metadata = await captureSourceJob(playwright, job); + console.log(`[theme-source] wrote ${metadata.outputDir}`); + console.log('[theme-source] files: screenshot.png, responsive/, theme.json, computed-tokens.json, meta.json'); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + runCli().catch((error) => { + console.error(`[theme-source] ${error.stack || error.message || error}`); + process.exitCode = 1; + }); +} diff --git a/scripts/capture-theme-source.test.mjs b/scripts/capture-theme-source.test.mjs new file mode 100644 index 0000000..d7cf440 --- /dev/null +++ b/scripts/capture-theme-source.test.mjs @@ -0,0 +1,105 @@ +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + inferThemeFromUrl, + parseResponsiveViewports, + parseSourceCaptureArgs, + resolveSourceCaptureJob, + summarizeTheme, +} from './capture-theme-source.mjs'; + +describe('capture-theme-source CLI planning', () => { + it('parses source capture options for screenshot and token evidence', () => { + const args = parseSourceCaptureArgs([ + 'node', + 'capture-theme-source.mjs', + 'https://www.trae.ai/', + '--theme', + 'trae-ai', + '--output', + '.local/theme-capture-trae-ai', + '--viewport', + '1440x900', + '--responsive-viewports', + 'desktop:1440x900,tablet:768x1024,mobile:390x844', + '--wait-for-selector', + 'main', + '--dismiss-selector', + 'button:has-text("Accept")', + '--remove-selector', + '.cookie-banner', + '--header', + 'Authorization=Bearer token', + '--connect-cdp', + 'http://localhost:9222', + '--no-scroll-warmup', + ]); + + expect(args.url).toBe('https://www.trae.ai/'); + expect(args.theme).toBe('trae-ai'); + expect(args.output).toBe('.local/theme-capture-trae-ai'); + expect(args.viewport).toEqual({ width: 1440, height: 900 }); + expect(args.responsiveViewports).toEqual([ + { name: 'desktop', width: 1440, height: 900 }, + { name: 'tablet', width: 768, height: 1024 }, + { name: 'mobile', width: 390, height: 844 }, + ]); + expect(args.waitForSelectors).toEqual(['main']); + expect(args.dismissSelectors).toEqual(['button:has-text("Accept")']); + expect(args.removeSelectors).toEqual(['.cookie-banner']); + expect(args.headers).toEqual({ Authorization: 'Bearer token' }); + expect(args.connectCdp).toBe('http://localhost:9222'); + expect(args.scrollWarmup).toBe(false); + }); + + it('resolves default output directory from a URL-derived theme key', () => { + const appRoot = '/repo/client'; + const workspaceRoot = '/repo'; + const args = parseSourceCaptureArgs([ + 'node', + 'capture-theme-source.mjs', + 'https://www.trae.ai/', + '--dry-run', + ]); + + const job = resolveSourceCaptureJob(args, { appRoot, workspaceRoot }); + + expect(job.theme).toBe('trae'); + expect(job.outputDir).toBe(path.join(appRoot, '.local/theme-capture-trae')); + expect(job.viewport).toEqual({ width: 1440, height: 900 }); + expect(job.responsive).toBe(true); + expect(job.screenshot).toBe(true); + expect(job.tokens).toBe(true); + }); + + it('normalizes responsive viewport lists and URL theme inference', () => { + expect(parseResponsiveViewports('wide:1600x900,narrow:375x812')).toEqual([ + { name: 'wide', width: 1600, height: 900 }, + { name: 'narrow', width: 375, height: 812 }, + ]); + expect(inferThemeFromUrl('https://www.example.co.uk/path')).toBe('example'); + }); + + it('keeps computed token summaries focused on theme evidence', () => { + const summary = summarizeTheme({ + colors: { text: [{ value: 'rgb(255, 255, 255)', count: 2 }] }, + typography: { families: [{ value: 'Inter', count: 3 }] }, + spacing: [{ value: '16px', count: 4 }], + ignored: 'not included', + }); + + expect(summary).toEqual({ + colors: { text: [{ value: 'rgb(255, 255, 255)', count: 2 }] }, + typography: { families: [{ value: 'Inter', count: 3 }] }, + spacing: [{ value: '16px', count: 4 }], + radius: [], + lineWidth: [], + shadow: {}, + transitions: [], + animations: [], + cssVariables: {}, + }); + }); +}); diff --git a/scripts/check-app-ready.mjs b/scripts/check-app-ready.mjs new file mode 100644 index 0000000..590e061 --- /dev/null +++ b/scripts/check-app-ready.mjs @@ -0,0 +1,878 @@ +#!/usr/bin/env node +/** + * ===================================================== + * CLI: check-app-ready + * + * 功能: + * - AI 调用,检测 Vite dev server 和页面状态 + * - 不依赖页面注入 + * - 捕获已存在和后续构建/热更新错误 + * - 页面可访问即 READY,出现错误即 ERROR + * - 超时返回 TIMEOUT + * - 默认包含构建校验,可通过 --skip-build 跳过 + * + * 使用: + * node scripts/check-app-ready.mjs [页面路径] + * 例如:node scripts/check-app-ready.mjs /prototypes/ref-app-home + * node scripts/check-app-ready.mjs /prototypes/home + * + * 跳过构建校验: + * node scripts/check-app-ready.mjs --skip-build /prototypes/ref-app-home + * + * 输出(JSON): + * { + * status: "READY" | "ERROR" | "TIMEOUT", + * phase: "server|build|page|done", + * message: "...", + * url: "http://localhost:51720/prototypes/ref-app-home", + * errors: [...], + * logs: [...], + * buildCheck?: { status: "SUCCESS" | "FAILED" | "SKIPPED", errors: [...], logs: [...] } + * lintCheck?: { status: "SUCCESS" | "FAILED" | "SKIPPED", errors: [...], logs: [...] } + * typeCheck?: { status: "SUCCESS" | "FAILED" | "SKIPPED", errors: [...], logs: [...] } + * checks?: [{ name: "lint|typecheck|build", status: "...", message: "...", errors: [...] }] + * homeUrl?: "http://localhost:51720" + * targetUrl?: "http://localhost:51720/prototypes/ref-app-home" + * targetPath?: "http://localhost:51720/prototypes/ref-app-home/index.html" + * } + * ===================================================== + */ + +import { spawn } from 'node:child_process' +import { setTimeout as sleep } from 'node:timers/promises' +import process from 'node:process' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { decodeOutput, getPreferredNpmCommand, getPreferredNpxCommand } from './utils/command-runtime.mjs' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const APP_ROOT = path.resolve(__dirname, '..') + +/* ================= 配置 ================= */ +// 解析命令行参数 +const args = process.argv.slice(2); +const skipBuild = args.includes('--skip-build'); +const pagePath = args.find(arg => !arg.startsWith('--')) || '/'; + +const CONFIG = { + devCommand: ['run', 'dev'], // 启动 Vite 的命令参数 + devServerInfoPath: path.resolve(__dirname, '../.axhub/make/.dev-server-info.json'), // 开发服务器信息文件 + pagePath, // 目标页面路径(从命令行参数获取) + pollIntervalMs: 500, // 页面轮询间隔 + stableCheckMs: 1000, // 错误稳定判断时间 + timeoutMs: 30_000, // 总超时 + skipBuild // 是否跳过构建校验 +} + +/* ================= 工具函数 ================= */ +function jsonExit(payload, code = 0) { + process.stdout.write(JSON.stringify(payload, null, 2)) + process.exit(code) +} + +/** + * 尝试通过 HTTP 请求获取页面内容,检查是否有错误信息 + */ +async function checkPageForErrors(url) { + try { + const res = await fetch(url, { method: 'GET' }) + const text = await res.text() + + // Only treat the HTML as an error page when Vite's overlay is present. + // The app template includes global error handlers with object literals like + // `{ error: event.error }`, which would otherwise cause false positives. + const hasViteOverlay = + text.includes('vite-error-overlay') || + text.includes('__vite_error_overlay__') || + /\[plugin:vite:/i.test(text) || + /Transform failed/i.test(text) + + if (!hasViteOverlay) return [] + + const errorPatterns = [ + /\bError:\s*([^\n]+)/, + /\bSyntaxError:\s*([^\n]+)/, + /\bReferenceError:\s*([^\n]+)/, + /\[plugin:vite:[^\]]+\]\s*([^\n]+)/i, + /Transform failed/i + ] + + for (const pattern of errorPatterns) { + const match = text.match(pattern) + if (match) return [match[1] || match[0]] + } + + return ['Detected Vite error overlay but could not extract message'] + } catch (err) { + return [] + } +} + +async function isServerAlive(url) { + try { + const res = await fetch(url, { method: 'GET' }) + return res.ok + } catch { + return false + } +} + +/** + * 读取开发服务器信息 + * 优先从 .axhub/make/.dev-server-info.json 读取实际运行的端口 + */ +function getServerInfo() { + try { + if (fs.existsSync(CONFIG.devServerInfoPath)) { + const info = JSON.parse(fs.readFileSync(CONFIG.devServerInfoPath, 'utf8')) + return { + port: info.port, + host: info.host || 'localhost', + localIP: info.localIP || 'localhost' + } + } + } catch (err) { + logs.push(`Failed to read .axhub/make/.dev-server-info.json: ${err.message}`) + } + + // 如果没有端口信息,返回 null 表示需要等待服务器启动 + return null +} + +/** + * 生成服务器首页 URL + * 使用 localhost 而不是 0.0.0.0,因为浏览器无法访问 0.0.0.0 + */ +function getHomeUrl(serverInfo) { + // 如果 host 是 0.0.0.0,使用 localhost 替代 + const host = serverInfo.host === '0.0.0.0' ? 'localhost' : serverInfo.host + return `http://${host}:${serverInfo.port}` +} + +/** + * 获取可访问的 host + * 将 0.0.0.0 转换为 localhost,因为浏览器无法直接访问 0.0.0.0 + */ +function getAccessibleHost(serverInfo) { + return serverInfo.host === '0.0.0.0' ? 'localhost' : serverInfo.host +} + +function getTargetUrl(serverInfo, targetPath) { + const host = getAccessibleHost(serverInfo) + return `http://${host}:${serverInfo.port}${targetPath}` +} + +function getEntryHtmlPath(targetPath) { + const normalized = targetPath.startsWith('/') ? targetPath : `/${targetPath}` + if (normalized.endsWith('.html')) return normalized + if (normalized.endsWith('/')) return `${normalized}index.html` + return `${normalized}/index.html` +} + +/* ================= 全局状态 ================= */ +let logs = [] +let errors = [] +let lastErrorTime = 0 +let errorCache = new Set() // 用于去重错误信息 + +/* ================= 阶段 1:启动或 attach Vite ================= */ +function startOrAttachVite() { + logs.push('Checking Vite server...') + const npmCommand = getPreferredNpmCommand() + const child = spawn(npmCommand, CONFIG.devCommand, { + stdio: ['ignore', 'pipe', 'pipe'], + cwd: APP_ROOT, + shell: false, + }) + + child.stdout.on('data', (data) => { + const text = decodeOutput(data).trim() + if (text) logs.push(text) + + // 检测构建错误 + if (/error/i.test(text) || /failed to compile/i.test(text)) { + errors.push(text) + lastErrorTime = Date.now() + } + }) + + child.stderr.on('data', (data) => { + const text = decodeOutput(data).trim() + if (text) { + // 过滤掉一些正常的警告信息 + if (!/deprecated|experimental/i.test(text)) { + errors.push(text) + lastErrorTime = Date.now() + } + logs.push(text) + } + }) + + child.on('error', (err) => { + errors.push(`Process error: ${err.message}`) + lastErrorTime = Date.now() + }) + + return child +} + +/* ================= 阶段 2:轮询页面可访问性 ================= */ +async function waitForPage(url) { + const start = Date.now() + let lastCheckTime = 0 + + while (Date.now() - start < CONFIG.timeoutMs) { + const now = Date.now() + + // 每隔一段时间尝试获取错误信息(即使页面不可访问) + if (now - lastCheckTime > 2000) { + const pageErrors = await checkPageForErrors(url) + if (pageErrors.length > 0) { + // 去重:只添加未见过的错误 + pageErrors.forEach(err => { + const errorKey = err.substring(0, 200) // 使用前200个字符作为唯一标识 + if (!errorCache.has(errorKey)) { + errorCache.add(errorKey) + errors.push(err) + } + }) + } + lastCheckTime = now + } + + if (await isServerAlive(url)) return true + await sleep(CONFIG.pollIntervalMs) + } + return false +} + +/* ================= 阶段 3:等待稳定状态 ================= */ +async function waitForStable(pageUrl) { + const startTime = Date.now() + + while (Date.now() - startTime < CONFIG.timeoutMs) { + const now = Date.now() + + // 页面可访问 + const pageOk = await isServerAlive(pageUrl) + + // 如果页面可访问,尝试检查页面内容中的错误 + if (pageOk) { + const pageErrors = await checkPageForErrors(pageUrl) + if (pageErrors.length > 0) { + return { + status: 'ERROR', + phase: 'build', + message: 'Detected error in page content', + url: pageUrl, + errors: pageErrors, + logs + } + } + } + + // 错误稳定:最近 stableCheckMs 内没有新的错误 + const stable = (now - lastErrorTime) > CONFIG.stableCheckMs + + if (!pageOk) { + // 页面不可访问,继续轮询 + await sleep(CONFIG.pollIntervalMs) + continue + } + + if (errors.length > 0) { + return { + status: 'ERROR', + phase: 'build', + message: 'Detected Vite build/runtime error', + url: pageUrl, + errors, + logs + } + } + + if (pageOk && stable) { + return { + status: 'READY', + phase: 'done', + message: 'Page ready and stable', + url: pageUrl, + errors: [], + logs + } + } + + await sleep(CONFIG.pollIntervalMs) + } + + return { + status: 'TIMEOUT', + phase: 'server', + message: 'Timeout waiting for page/stable state', + url: pageUrl, + errors, + logs + } +} + +/** + * 为结果添加服务器首页信息 + */ +function addUrls(result, serverInfo) { + if (!serverInfo) { + return { + ...result, + homeUrl: null, + targetUrl: null, + targetPath: null + } + } + + const entryHtmlPath = getEntryHtmlPath(CONFIG.pagePath) + + return { + ...result, + homeUrl: getHomeUrl(serverInfo), + targetUrl: getTargetUrl(serverInfo, CONFIG.pagePath), + targetPath: getTargetUrl(serverInfo, entryHtmlPath) + } +} + +function readPackageJson() { + const pkgPath = path.resolve(APP_ROOT, 'package.json') + try { + return JSON.parse(fs.readFileSync(pkgPath, 'utf8')) + } catch (err) { + logs.push(`Failed to read package.json: ${err.message}`) + return null + } +} + +function getScriptCommand(pkgJson, scriptName) { + if (!pkgJson || !pkgJson.scripts) return null + return pkgJson.scripts[scriptName] || null +} + +function hasEslintConfig(pkgJson) { + if (pkgJson && pkgJson.eslintConfig) return true + const configFiles = [ + '.eslintrc', + '.eslintrc.js', + '.eslintrc.cjs', + '.eslintrc.json', + '.eslintrc.yaml', + '.eslintrc.yml', + 'eslint.config.js', + 'eslint.config.cjs', + 'eslint.config.mjs' + ] + return configFiles.some((file) => fs.existsSync(path.resolve(APP_ROOT, file))) +} + +function hasTsConfig() { + return fs.existsSync(path.resolve(APP_ROOT, 'tsconfig.json')) +} + +function toCheckItem(name, result) { + if (!result) return null + return { + name, + status: result.status, + message: result.message, + errors: result.errors || [] + } +} + +function buildChecksSummary({ lintResult, typeCheckResult, buildResult }) { + return [ + toCheckItem('lint', lintResult), + toCheckItem('typecheck', typeCheckResult), + toCheckItem('build', buildResult) + ].filter(Boolean) +} + +async function runCommandCheck({ label, command, args = [], env = {}, logTag }) { + logs.push(`${label} check started`) + + return new Promise((resolve) => { + const checkErrors = [] + const checkLogs = [] + const resolvedCommand = command === 'npm' + ? getPreferredNpmCommand() + : command === 'npx' + ? getPreferredNpxCommand() + : command + + const proc = spawn(resolvedCommand, args, { + cwd: APP_ROOT, + env: { ...process.env, ...env }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + const appendLog = (line, isError = false) => { + if (!line) return + checkLogs.push(line) + logs.push(`[${logTag}] ${line}`) + if (isError && !/deprecated|experimental/i.test(line)) { + checkErrors.push(line) + } + } + + proc.stdout.on('data', (data) => { + appendLog(decodeOutput(data).trim(), false) + }) + + proc.stderr.on('data', (data) => { + appendLog(decodeOutput(data).trim(), true) + }) + + proc.on('close', (code) => { + if (code === 0 && checkErrors.length === 0) { + resolve({ + status: 'SUCCESS', + message: `${label} completed successfully`, + errors: [], + logs: checkLogs + }) + return + } + + resolve({ + status: 'FAILED', + message: `${label} failed (exit code: ${code})`, + errors: checkErrors.length > 0 ? checkErrors : [`${label} exited with code ${code}`], + logs: checkLogs + }) + }) + + proc.on('error', (err) => { + logs.push(`${label} process error: ${err.message}`) + resolve({ + status: 'FAILED', + message: `${label} process error: ${err.message}`, + errors: [err.message], + logs: checkLogs + }) + }) + }) +} + +async function runLintCheck() { + const pkgJson = readPackageJson() + const lintScript = getScriptCommand(pkgJson, 'lint') + + if (lintScript) { + return runCommandCheck({ + label: 'Lint', + command: 'npm', + args: ['run', 'lint'], + logTag: 'LINT' + }) + } + + if (!hasEslintConfig(pkgJson)) { + return { + status: 'SKIPPED', + message: 'Lint skipped: no eslint config or lint script found', + errors: [], + logs: [] + } + } + + return runCommandCheck({ + label: 'Lint', + command: 'npx', + args: ['eslint', '.'], + logTag: 'LINT' + }) +} + +async function runTypeCheck() { + const pkgJson = readPackageJson() + const typecheckScript = getScriptCommand(pkgJson, 'typecheck') + + if (typecheckScript) { + return runCommandCheck({ + label: 'Typecheck', + command: 'npm', + args: ['run', 'typecheck'], + logTag: 'TYPECHECK' + }) + } + + if (!hasTsConfig()) { + return { + status: 'SKIPPED', + message: 'Typecheck skipped: no tsconfig.json or typecheck script found', + errors: [], + logs: [] + } + } + + return runCommandCheck({ + label: 'Typecheck', + command: 'npx', + args: ['tsc', '--noEmit'], + logTag: 'TYPECHECK' + }) +} + +/** + * 扫描并更新 .axhub/make/entries.json + * 确保新创建的目录被包含在入口列表中 + */ +async function scanEntries() { + logs.push('Scanning entries...') + + return new Promise((resolve) => { + const scanProcess = spawn(process.execPath, ['scripts/scan-entries.js'], { + cwd: APP_ROOT, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + scanProcess.stdout.on('data', (data) => { + const text = decodeOutput(data).trim() + if (text) logs.push(`[SCAN] ${text}`) + }) + + scanProcess.stderr.on('data', (data) => { + const text = decodeOutput(data).trim() + if (text) logs.push(`[SCAN ERROR] ${text}`) + }) + + scanProcess.on('close', (code) => { + if (code === 0) { + logs.push('Entry scanning completed') + resolve({ success: true }) + } else { + logs.push(`Entry scanning failed with exit code ${code}`) + resolve({ success: false }) + } + }) + + scanProcess.on('error', (err) => { + logs.push(`Entry scanning error: ${err.message}`) + resolve({ success: false }) + }) + }) +} + +/** + * 执行独立构建校验 + * 针对指定的入口 key 执行单独构建,不是全量构建 + */ +async function runBuildCheck(entryKey) { + const originalEntryKey = String(entryKey ?? '').trim() + logs.push(`Starting build check for entry: ${originalEntryKey || '(auto)'}`) + + // 先扫描入口,确保 .axhub/make/entries.json 是最新的 + const scanResult = await scanEntries() + if (!scanResult.success) { + return { + status: 'FAILED', + message: 'Failed to scan entries before build', + errors: ['Entry scanning failed'], + logs: [] + } + } + + const resolvedEntryKey = originalEntryKey || resolveDefaultEntryKey() + if (!resolvedEntryKey) { + logs.push('Build check skipped: no entry key resolved') + return { + status: 'SKIPPED', + message: 'Build check skipped: no entry key resolved', + errors: [], + logs: [] + } + } + + return new Promise((resolve) => { + const buildErrors = [] + const buildLogs = [] + const npxCommand = getPreferredNpxCommand() + + // 使用 ENTRY_KEY 环境变量触发单独构建 + const buildProcess = spawn(npxCommand, ['vite', 'build'], { + cwd: APP_ROOT, + env: { ...process.env, ENTRY_KEY: resolvedEntryKey }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + buildProcess.stdout.on('data', (data) => { + const text = decodeOutput(data).trim() + if (text) { + buildLogs.push(text) + logs.push(`[BUILD] ${text}`) + } + }) + + buildProcess.stderr.on('data', (data) => { + const text = decodeOutput(data).trim() + if (text) { + buildLogs.push(text) + logs.push(`[BUILD ERROR] ${text}`) + // 捕获构建错误 + if (/error|failed/i.test(text) && !/deprecated|experimental/i.test(text)) { + buildErrors.push(text) + } + } + }) + + buildProcess.on('close', (code) => { + if (code === 0 && buildErrors.length === 0) { + logs.push(`Build check completed successfully for ${resolvedEntryKey}`) + resolve({ + status: 'SUCCESS', + message: `Build completed successfully for ${resolvedEntryKey}`, + errors: [], + logs: buildLogs + }) + } else { + logs.push(`Build check failed for ${resolvedEntryKey} with exit code ${code}`) + resolve({ + status: 'FAILED', + message: `Build failed for ${resolvedEntryKey} (exit code: ${code})`, + errors: buildErrors.length > 0 ? buildErrors : [`Build process exited with code ${code}`], + logs: buildLogs + }) + } + }) + + buildProcess.on('error', (err) => { + logs.push(`Build process error: ${err.message}`) + resolve({ + status: 'FAILED', + message: `Build process error: ${err.message}`, + errors: [err.message], + logs: buildLogs + }) + }) + }) +} + +function resolveDefaultEntryKey() { + try { + const entriesPath = path.resolve(APP_ROOT, '.axhub/make/entries.json') + if (!fs.existsSync(entriesPath)) return null + const raw = JSON.parse(fs.readFileSync(entriesPath, 'utf8')) + const jsEntries = raw && typeof raw === 'object' ? (raw.js || {}) : {} + const keys = Object.keys(jsEntries || {}).filter(Boolean).sort((a, b) => a.localeCompare(b)) + if (keys.length === 0) return null + + const pickFromPrefix = (prefix) => keys.find((k) => k.startsWith(prefix)) + return ( + pickFromPrefix('prototypes/') || + pickFromPrefix('themes/') || + keys[0] || + null + ) + } catch (err) { + logs.push(`Failed to resolve default entry key: ${err.message}`) + return null + } +} + +/** + * 从页面路径推断入口 key + * 例如:/prototypes/ref-app-home -> prototypes/ref-app-home + */ +function getEntryKeyFromPath(pagePath) { + // 移除开头的斜杠 + return pagePath.replace(/^\//, '') +} + +/* ================= 主流程 ================= */ +async function main() { + try { + // 获取服务器信息 + const serverInfo = getServerInfo() + + // 如果没有端口信息,等待服务器启动 + if (!serverInfo) { + logs.push('Waiting for server to start...') + // 启动服务器并等待 + const viteProcess = startOrAttachVite() + + // 等待 .axhub/make/.dev-server-info.json 文件生成 + const maxWait = 10000 // 10秒 + const startTime = Date.now() + let newServerInfo = null + + while (Date.now() - startTime < maxWait) { + await new Promise(resolve => setTimeout(resolve, 500)) + newServerInfo = getServerInfo() + if (newServerInfo) break + } + + if (!newServerInfo) { + return jsonExit(addUrls({ + status: 'ERROR', + phase: 'server', + message: 'Server failed to start - no port information available', + url: CONFIG.pagePath, + errors: ['Server did not write port information within timeout'], + logs + }, null), 1) + } + + // 使用新获取的服务器信息 + const accessibleHost = getAccessibleHost(newServerInfo) + const pageUrl = `http://${accessibleHost}:${newServerInfo.port}${CONFIG.pagePath}` + + logs.push(`Target URL: ${pageUrl}`) + logs.push(`Server info: port=${newServerInfo.port}, host=${newServerInfo.host}`) + + // 继续后续流程... + await continueWithServerInfo(newServerInfo, pageUrl, viteProcess) + } else { + const accessibleHost = getAccessibleHost(serverInfo) + const pageUrl = `http://${accessibleHost}:${serverInfo.port}${CONFIG.pagePath}` + + logs.push(`Target URL: ${pageUrl}`) + logs.push(`Server info: port=${serverInfo.port}, host=${serverInfo.host}`) + + // 继续后续流程... + await continueWithServerInfo(serverInfo, pageUrl, null) + } + } catch (err) { + const serverInfo = getServerInfo() + jsonExit(addUrls({ + status: 'ERROR', + phase: 'server', + message: err.message, + url: CONFIG.pagePath, + errors: [String(err)], + logs + }, serverInfo), 1) + } +} + +async function continueWithServerInfo(serverInfo, pageUrl, viteProcess) { + try { + // 步骤 1: 执行 lint 检查 + const lintResult = await runLintCheck() + if (lintResult.status === 'FAILED') { + return jsonExit(addUrls({ + status: 'ERROR', + phase: 'lint', + message: lintResult.message, + url: pageUrl, + errors: lintResult.errors, + logs, + lintCheck: lintResult, + checks: buildChecksSummary({ lintResult }) + }, serverInfo), 1) + } + + // 步骤 2: 执行 typecheck 检查 + const typeCheckResult = await runTypeCheck() + if (typeCheckResult.status === 'FAILED') { + return jsonExit(addUrls({ + status: 'ERROR', + phase: 'typecheck', + message: typeCheckResult.message, + url: pageUrl, + errors: typeCheckResult.errors, + logs, + lintCheck: lintResult, + typeCheck: typeCheckResult, + checks: buildChecksSummary({ lintResult, typeCheckResult }) + }, serverInfo), 1) + } + + // 步骤 3: 执行构建校验(除非指定 --skip-build) + let buildResult = null + if (!CONFIG.skipBuild) { + const entryKey = getEntryKeyFromPath(CONFIG.pagePath) + logs.push(`Build check enabled for entry: ${entryKey}`) + buildResult = await runBuildCheck(entryKey) + + // 如果构建失败,直接返回错误 + if (buildResult.status === 'FAILED') { + return jsonExit(addUrls({ + status: 'ERROR', + phase: 'build', + message: buildResult.message, + url: pageUrl, + errors: buildResult.errors, + logs, + buildCheck: buildResult, + lintCheck: lintResult, + typeCheck: typeCheckResult, + checks: buildChecksSummary({ lintResult, typeCheckResult, buildResult }) + }, serverInfo), 1) + } + } else { + logs.push('Build check skipped (--skip-build flag)') + buildResult = { + status: 'SKIPPED', + message: 'Build check skipped (--skip-build flag)', + errors: [], + logs: [] + } + } + + // 步骤 4: 开发服务器校验 + const accessibleHost = getAccessibleHost(serverInfo) + + // 检查服务器是否已经在运行 + const serverAlreadyRunning = await isServerAlive(`http://${accessibleHost}:${serverInfo.port}`) + + let viteChild = viteProcess + if (!serverAlreadyRunning && !viteChild) { + logs.push('Server not running, starting Vite...') + viteChild = startOrAttachVite() + } else { + logs.push('Server already running, skipping start') + } + + // 等待页面可访问 + const pageReachable = await waitForPage(pageUrl) + if (!pageReachable) { + if (viteChild) viteChild.kill() + return jsonExit(addUrls({ + status: 'TIMEOUT', + phase: 'page', + message: 'Page never became reachable', + url: pageUrl, + errors, + logs, + buildCheck: buildResult, + lintCheck: lintResult, + typeCheck: typeCheckResult, + checks: buildChecksSummary({ lintResult, typeCheckResult, buildResult }) + }, serverInfo), 1) + } + + // 等待稳定状态 + const result = await waitForStable(pageUrl) + + // 清理进程 + if (viteChild) viteChild.kill() + + // 添加构建结果到最终输出 + const finalResult = { + ...result, + buildCheck: buildResult, + lintCheck: lintResult, + typeCheck: typeCheckResult, + checks: buildChecksSummary({ lintResult, typeCheckResult, buildResult }) + } + + jsonExit(addUrls(finalResult, serverInfo), result.status === 'READY' ? 0 : 1) + } catch (err) { + jsonExit(addUrls({ + status: 'ERROR', + phase: 'server', + message: err.message, + url: CONFIG.pagePath, + errors: [String(err)], + logs + }, serverInfo), 1) + } +} + +main() diff --git a/scripts/chrome-export-converter.mjs b/scripts/chrome-export-converter.mjs new file mode 100644 index 0000000..88faff7 --- /dev/null +++ b/scripts/chrome-export-converter.mjs @@ -0,0 +1,873 @@ +#!/usr/bin/env node + +/** + * ===================================================== + * Chrome 扩展导出转换器 + * + * 专门处理通过 Chrome 扩展本项目导出的 HTML 文件 + * + * 功能: + * 1. 转换 index.html 为 React 组件 + * 2. 智能处理字体:CDN 保留链接,本地文件复制 + * 3. 复制静态资源(图片、字体) + * 4. 保留完整的 style.css 样式 + * ===================================================== + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { assertValidGeneratedTsx } from './utils/generatedTsxValidator.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const CONFIG = { + projectRoot: path.resolve(__dirname, '..'), + pagesDir: path.resolve(__dirname, '../src/prototypes') +}; + +const JSX_ATTRIBUTE_REPLACEMENTS = [ + ['class', 'className'], + ['for', 'htmlFor'], + ['tabindex', 'tabIndex'], + ['readonly', 'readOnly'], + ['maxlength', 'maxLength'], + ['minlength', 'minLength'], + ['colspan', 'colSpan'], + ['rowspan', 'rowSpan'], + ['viewbox', 'viewBox'], + ['preserveaspectratio', 'preserveAspectRatio'], + ['clip-path', 'clipPath'], + ['fill-rule', 'fillRule'], + ['clip-rule', 'clipRule'], + ['stroke-width', 'strokeWidth'], + ['stroke-dasharray', 'strokeDasharray'], + ['stroke-dashoffset', 'strokeDashoffset'], + ['stroke-linecap', 'strokeLinecap'], + ['stroke-linejoin', 'strokeLinejoin'], + ['stroke-miterlimit', 'strokeMiterlimit'], + ['stroke-opacity', 'strokeOpacity'], + ['fill-opacity', 'fillOpacity'], + ['stop-color', 'stopColor'], + ['stop-opacity', 'stopOpacity'], + ['xlink:href', 'xlinkHref'], + ['xmlns:xlink', 'xmlnsXlink'], +]; + +function log(message, type = 'info') { + const prefix = { info: '✓', warn: '⚠', error: '✗', progress: '⏳' }[type] || 'ℹ'; + console.log(`${prefix} ${message}`); +} + +function ensureDir(dirPath) { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +} + +function normalizeRelativeDir(value) { + return String(value ?? '') + .replace(/\\/g, '/') + .split('/') + .filter(Boolean) + .join('/'); +} + +function isSafeRelativeDir(value) { + if (!value) return false; + if (value.startsWith('/') || value.startsWith('~')) return false; + const segments = value.split('/'); + if (segments.length === 0) return false; + return segments.every((segment) => { + if (!segment || segment === '.' || segment === '..') return false; + return !/[\\/]/.test(segment); + }); +} + +/** + * 判断是否为 CDN 链接 + */ +function isCDNUrl(url) { + return url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//'); +} + +/** + * 递归复制目录 + */ +function copyDirectory(src, dest) { + if (!fs.existsSync(src)) return 0; + + ensureDir(dest); + const entries = fs.readdirSync(src, { withFileTypes: true }); + let count = 0; + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + count += copyDirectory(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + count++; + } + } + + return count; +} + +/** + * 提取 head 内容(仅处理外部资源和字体) + */ +function extractHeadContent(html) { + const headMatch = html.match(/]*>([\s\S]*?)<\/head>/i); + if (!headMatch) return { scripts: [], links: [] }; + + const headContent = headMatch[1]; + const scripts = []; + const links = []; + + // 提取 script 标签(排除 Tailwind CDN) + const scriptRegex = /]*)>([\s\S]*?)<\/script>/gi; + let match; + while ((match = scriptRegex.exec(headContent)) !== null) { + const attrs = match[1]; + const content = match[2].trim(); + + const srcMatch = attrs.match(/src=["']([^"']+)["']/); + if (srcMatch) { + const src = srcMatch[1].replace(/&/g, '&'); + // 跳过 Tailwind CDN + if (src.includes('tailwindcss.com')) continue; + + scripts.push({ + src, + id: attrs.match(/id=["']([^"']+)["']/)?.[1] + }); + } else if (content) { + const id = attrs.match(/id=["']([^"']+)["']/)?.[1]; + scripts.push({ id, content }); + } + } + + // 提取 link 标签 + const linkRegex = /]*>/gi; + while ((match = linkRegex.exec(headContent)) !== null) { + const tag = match[0]; + const href = tag.match(/href=["']([^"']+)["']/)?.[1]; + if (href) { + links.push({ + href: href.replace(/&/g, '&'), + rel: tag.match(/rel=["']([^"']+)["']/)?.[1] || 'stylesheet', + crossorigin: tag.includes('crossorigin') + }); + } + } + + return { scripts, links }; +} + +/** + * 转义文本节点中的花括号 + * 只处理标签之间的文本内容,不处理属性值 + */ +function escapeTextBraces(html) { + const parts = []; + let lastIndex = 0; + const tagRegex = /<[^>]+>/g; + let match; + + while ((match = tagRegex.exec(html)) !== null) { + // 提取标签之前的文本 + const textBefore = html.substring(lastIndex, match.index); + if (textBefore) { + // 转义文本中的花括号 - 使用占位符避免重复替换 + const escaped = textBefore + .replace(/\{/g, "__LBRACE__") + .replace(/\}/g, "__RBRACE__"); + parts.push(escaped); + } + // 添加标签本身(不转义) + parts.push(match[0]); + lastIndex = tagRegex.lastIndex; + } + + // 添加最后一段文本 + const textAfter = html.substring(lastIndex); + if (textAfter) { + const escaped = textAfter + .replace(/\{/g, "__LBRACE__") + .replace(/\}/g, "__RBRACE__"); + parts.push(escaped); + } + + return parts.join('') + .replace(/__LBRACE__/g, "{'{'}") + .replace(/__RBRACE__/g, "{'}'}") +} + +function convertCommonAttributesToJSX(content) { + let nextContent = content; + + nextContent = nextContent.replace(/\s(?:srcset|sizes)=(["'])\1/gi, ''); + + JSX_ATTRIBUTE_REPLACEMENTS.forEach(([from, to]) => { + nextContent = nextContent.replace(new RegExp(`(\\s)${from}=`, 'gi'), `$1${to}=`); + }); + + return nextContent; +} + +function findCompatibleImageAsset(filename, availableAssets) { + if (!filename || availableAssets.has(filename)) { + return filename; + } + + const extension = path.extname(filename); + const basename = path.basename(filename, extension); + const dedupeMatch = basename.match(/^(.*?)-\d+$/); + if (!dedupeMatch) { + return filename; + } + + const fallbackName = `${dedupeMatch[1]}${extension}`; + return availableAssets.has(fallbackName) ? fallbackName : filename; +} + +function reconcileImageAssetReferences(content, imagesPath) { + if (!content || !fs.existsSync(imagesPath)) { + return content; + } + + const availableAssets = new Set( + fs.readdirSync(imagesPath, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name), + ); + + return content.replace(/assets\/images\/([^"')\s>]+)/g, (fullMatch, filename) => { + const resolvedFilename = findCompatibleImageAsset(filename, availableAssets); + if (resolvedFilename === filename) { + return fullMatch; + } + return `assets/images/${resolvedFilename}`; + }); +} + +function createCommentPlaceholders(content) { + const comments = []; + const withPlaceholders = content.replace(//g, (_, commentBody) => { + const placeholder = `__HTML_COMMENT_${comments.length}__`; + comments.push(`{/* ${commentBody} */}`); + return placeholder; + }); + + return { withPlaceholders, comments }; +} + +function restoreCommentPlaceholders(content, comments) { + return comments.reduce( + (currentContent, comment, index) => currentContent.replaceAll(`__HTML_COMMENT_${index}__`, comment), + content, + ); +} + +function convertHtmlToJSX(content) { + let nextContent = convertCommonAttributesToJSX(content) + .replace(/(]*>)([\s\S]*?)(<\/pre>)/gi, (_, openTag, preContent) => { + const escapedContent = preContent + .replace(/\\/g, '\\\\') + .replace(/`/g, '\\`') + .replace(/\$/g, '\\$') + .replace(/\{/g, '\\{'); + return `${openTag.slice(0, -1)} dangerouslySetInnerHTML={{ __html: \`${escapedContent}\` }} />`; + }) + .replace(/style='([^']*)'/gi, (_, styleStr) => convertStyleToJSX(styleStr)) + .replace(/style="([^"]*)"/gi, (_, styleStr) => convertStyleToJSX(styleStr)) + .replace(/<\/(br|hr|img|input|meta|link)>/gi, '') + .replace(/<(br|hr|img|input|meta|link)([^>]*)>/gi, '<$1$2 />') + .replace(/]*)>/gi, '
') + .replace(/<\/body>/gi, '
') + .replace(/<\//g, '__LTSLASH__') + .replace(/</g, '__LT__') + .replace(/>/g, '__GT__') + .replace(/&/g, '__AMP__'); + + const { withPlaceholders, comments } = createCommentPlaceholders(nextContent); + nextContent = escapeTextBraces(withPlaceholders); + nextContent = restoreCommentPlaceholders(nextContent, comments); + + return nextContent + .replace(/__LTSLASH__/g, "{''}") + .replace(/__AMP__/g, '&'); +} + +/** + * 提取并转换 body 内容 + */ +function extractBodyContent(html) { + const openTagMatch = html.match(/]*>/i); + if (!openTagMatch || openTagMatch.index === undefined) return ''; + + const openTag = openTagMatch[0]; + const contentStart = openTagMatch.index + openTag.length; + const closeTagIndex = html.toLowerCase().lastIndexOf(''); + if (closeTagIndex < contentStart) return ''; + + const innerContent = html.slice(contentStart, closeTagIndex); + + // 移除 标签(Chrome 扩展导出特有的包装标签) + let cleanedContent = innerContent.trim() + .replace(/^\s*\s*/i, '') + .replace(/\s*<\/root>\s*$/i, ''); + + const convertedOpenTag = convertCommonAttributesToJSX(openTag) + .replace(/^`; +} + +function convertStyleToJSX(styleStr) { + if (!styleStr.trim()) return 'style={{}}'; + + // 先解码 HTML 实体 + const decodedStr = styleStr + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); + + const styles = []; + let currentProp = ''; + let inUrl = false; + + for (let i = 0; i < decodedStr.length; i++) { + const char = decodedStr[i]; + if (char === '(' && decodedStr.substring(i - 3, i) === 'url') inUrl = true; + else if (char === ')' && inUrl) inUrl = false; + + if (char === ';' && !inUrl) { + if (currentProp.trim()) styles.push(currentProp.trim()); + currentProp = ''; + } else { + currentProp += char; + } + } + if (currentProp.trim()) styles.push(currentProp.trim()); + + const jsxStyles = styles + .filter(s => s.includes(':')) + .map(s => { + const colonIndex = s.indexOf(':'); + const key = s.substring(0, colonIndex).trim(); + const value = s.substring(colonIndex + 1).trim(); + if (!key || !value) return ''; + + const camelKey = key.startsWith('-') + ? JSON.stringify(key) + : key.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); + let jsxValue; + if (value.startsWith('url(') || value.includes('var(')) { + jsxValue = `'${value.replace(/'/g, "\\'")}'`; + } else if (/^-?\d+(\.\d+)?$/.test(value)) { + jsxValue = value; + } else { + // 转义单引号和反斜杠 + const escapedValue = value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + jsxValue = `'${escapedValue}'`; + } + return `${camelKey}: ${jsxValue}`; + }) + .filter(Boolean) + .join(', '); + + return `style={{ ${jsxStyles} }}`; +} + +/** + * 生成组件代码 + */ +function normalizeDisplayName(displayName) { + const text = String(displayName ?? '').trim(); + const singleLine = text.replace(/\r?\n/g, ' '); + const safeText = singleLine.replace(/\*\//g, '* /'); + return safeText.slice(0, 200); +} + +function generateComponent(pageSlug, displayName, bodyContent, headContent) { + const componentName = pageSlug + .split(/[-_\s]+/) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); + const safeDisplayName = normalizeDisplayName(displayName || pageSlug); + + let cleanedContent = bodyContent.trim(); + if (cleanedContent.startsWith('{/*')) { + const firstTagIndex = cleanedContent.indexOf('<'); + if (firstTagIndex > 0) { + cleanedContent = cleanedContent.substring(firstTagIndex); + } + } + + const needsWrapper = !isWrappedInSingleElement(cleanedContent); + const finalContent = needsWrapper ? `<>\n${cleanedContent}\n ` : cleanedContent; + + // 生成注入代码 + let injectionCode = ''; + + if (headContent.links.length > 0 || headContent.scripts.length > 0) { + const hasExternalScripts = headContent.scripts.some(s => s.src); + + injectionCode = ` + // 动态注入外部资源 + React.useEffect(function () { + const injected: (HTMLElement)[] = []; + `; + + if (headContent.links.length > 0) { + injectionCode += ` + // 注入 links + ${JSON.stringify(headContent.links)}.forEach(function (linkInfo: any) { + const existing = document.querySelector(\`link[href="\${linkInfo.href}"]\`); + if (!existing) { + const link = document.createElement('link'); + link.rel = linkInfo.rel; + link.href = linkInfo.href; + if (linkInfo.crossorigin) link.crossOrigin = 'anonymous'; + document.head.appendChild(link); + injected.push(link); + } + }); + `; + } + + if (hasExternalScripts) { + injectionCode += ` + // 注入外部脚本 + ${JSON.stringify(headContent.scripts.filter(s => s.src))}.forEach(function (scriptInfo: any) { + const existing = document.querySelector(\`script[src="\${scriptInfo.src}"]\`); + if (!existing) { + const script = document.createElement('script'); + if (scriptInfo.id) script.id = scriptInfo.id; + script.src = scriptInfo.src; + document.head.appendChild(script); + injected.push(script); + } + }); + `; + } + + injectionCode += ` + return function () { + injected.forEach(function (el) { + if (el.parentNode) el.parentNode.removeChild(el); + }); + }; + }, []); +`; + } + + return `/** + * @name ${safeDisplayName} + * + * 参考资料: + * - /rules/development-guide.md + * - /rules/default-resource-recommendations.md + */ + +import './style.css'; +import React, { forwardRef, useImperativeHandle } from 'react'; +import type { AxureProps, AxureHandle } from '../../common/axure-types'; + +class ErrorBoundary extends React.Component< + { children: React.ReactNode }, + { hasError: boolean; error: Error | null } +> { + constructor(props: any) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('[${pageSlug}] 组件渲染错误:', error); + console.error('[${pageSlug}] 错误详情:', errorInfo); + console.error('[${pageSlug}] 错误堆栈:', error.stack); + } + + render() { + if (this.state.hasError) { + return ( +
+

组件渲染失败: ${safeDisplayName}

+
+            {this.state.error?.toString()}
+            {this.state.error?.stack}
+          
+
+ ); + } + + return this.props.children; + } +} + +const Component = forwardRef(function ${componentName}( + innerProps: AxureProps, + ref: React.ForwardedRef, +) { + console.log('[${pageSlug}] 组件开始渲染'); + + useImperativeHandle(ref, function () { + return { + getVar: function () { return undefined; }, + fireAction: function () {}, + eventList: [], + actionList: [], + varList: [], + configList: [], + dataList: [] + }; + }, []); +${injectionCode} + console.log('[${pageSlug}] 准备返回 JSX'); + + try { + return ( +${finalContent.split('\n').map(line => ' ' + line).join('\n')} + ); + } catch (error) { + console.error('[${pageSlug}] JSX 渲染错误:', error); + throw error; + } +}); + +const WrappedComponent = forwardRef(function WrappedComponent( + props: AxureProps, + ref: React.ForwardedRef, +) { + return ( + + + + ); +}); + +export default WrappedComponent; +`; +} + +function isWrappedInSingleElement(content) { + const trimmed = content.trim(); + if (!trimmed.startsWith('<')) return false; + if (trimmed.startsWith(''); + + const firstTagMatch = trimmed.match(/^<([a-zA-Z][a-zA-Z0-9]*)/); + if (!firstTagMatch) return false; + + const tagName = firstTagMatch[1]; + const closingTag = ``; + if (!trimmed.endsWith(closingTag)) return false; + + const openCount = (trimmed.match(new RegExp(`<${tagName}[\\s>]`, 'g')) || []).length; + const closeCount = (trimmed.match(new RegExp(``, 'g')) || []).length; + return openCount === closeCount && openCount === 1; +} + +/** + * 生成 CSS 文件(仅处理外部 style.css) + */ +function generateStyleCSS(fonts, sourcePath) { + let css = '@import "tailwindcss";\n'; + + // 处理字体 + if (fonts && fonts.length > 0) { + css += '\n/* 字体定义 */\n'; + + const cdnFonts = fonts.filter(f => f.isCDN); + const localFonts = fonts.filter(f => !f.isCDN); + + if (cdnFonts.length > 0) { + css += '\n/* CDN 字体(保留原始链接) */\n'; + cdnFonts.forEach(font => { + css += font.rule + '\n\n'; + }); + } + + if (localFonts.length > 0) { + css += '\n/* 本地字体(已复制到 assets 目录) */\n'; + localFonts.forEach(font => { + // 将字体路径改为相对于 style.css 的路径 + const modifiedRule = font.rule.replace( + /url\(['"]?([^'")\s]+)['"]?\)/g, + (match, url) => `url('./${url}')` + ); + css += modifiedRule + '\n\n'; + }); + } + } + + // 读取外部 CSS 文件的完整内容(排除字体定义) + const externalCSSPath = path.join(sourcePath, 'style.css'); + if (fs.existsSync(externalCSSPath)) { + const externalCSS = fs.readFileSync(externalCSSPath, 'utf8'); + // 移除 @font-face 规则(已单独处理) + const withoutFontFace = externalCSS.replace(/@font-face\s*\{[^}]+\}/g, '').trim(); + if (withoutFontFace) { + css += '\n/* 样式类定义(来自 style.css)*/\n'; + css += withoutFontFace + '\n'; + } + } + + return css; +} + +/** + * 从外部 CSS 文件提取字体 + */ +function extractFontsFromCSS(cssPath) { + if (!fs.existsSync(cssPath)) return []; + + const css = fs.readFileSync(cssPath, 'utf8'); + const fonts = []; + + const fontFaceRegex = /@font-face\s*\{([^}]+)\}/g; + let match; + while ((match = fontFaceRegex.exec(css)) !== null) { + const fontRule = match[1]; + const srcMatch = fontRule.match(/src:\s*url\(['"]?([^'")\s]+)['"]?\)/); + const familyMatch = fontRule.match(/font-family:\s*['"]([^'"]+)['"]/); + + if (srcMatch && familyMatch) { + const fontSrc = srcMatch[1]; + const fontFamily = familyMatch[1]; + + fonts.push({ + family: fontFamily, + src: fontSrc, + isCDN: isCDNUrl(fontSrc), + rule: match[0] + }); + } + } + + return fonts; +} + +/** + * 转换单个页面 + */ +function convertPage(sourcePath, outputDir, pageSlug, displayName) { + log(`正在转换页面: ${pageSlug}`, 'progress'); + + // Chrome 扩展导出固定使用 index.html + const htmlPath = path.join(sourcePath, 'index.html'); + + if (!fs.existsSync(htmlPath)) { + throw new Error(`找不到 index.html 文件: ${htmlPath}`); + } + + const html = fs.readFileSync(htmlPath, 'utf8'); + + const headContent = extractHeadContent(html); + const bodyContent = reconcileImageAssetReferences( + extractBodyContent(html), + path.join(sourcePath, 'assets', 'images'), + ); + + // 从外部 CSS 文件提取字体 + const externalCSSPath = path.join(sourcePath, 'style.css'); + let fonts = []; + if (fs.existsSync(externalCSSPath)) { + fonts = extractFontsFromCSS(externalCSSPath); + if (fonts.length > 0) { + log(` ✓ 从 style.css 提取了 ${fonts.length} 个字体定义`, 'info'); + } + } + + ensureDir(outputDir); + + // 生成组件和样式 + const componentCode = generateComponent(pageSlug, displayName, bodyContent, headContent); + const styleCSS = generateStyleCSS(fonts, sourcePath); + const outputTsxPath = path.join(outputDir, 'index.tsx'); + + assertValidGeneratedTsx(componentCode, outputTsxPath); + + fs.writeFileSync(outputTsxPath, componentCode); + fs.writeFileSync(path.join(outputDir, 'style.css'), styleCSS); + + // 复制静态资源 + const assetsPath = path.join(sourcePath, 'assets'); + if (fs.existsSync(assetsPath)) { + const outputAssetsPath = path.join(outputDir, 'assets'); + + // 复制图片 + const imagesPath = path.join(assetsPath, 'images'); + if (fs.existsSync(imagesPath)) { + const imageCount = copyDirectory(imagesPath, path.join(outputAssetsPath, 'images')); + if (imageCount > 0) { + log(` ✓ 复制了 ${imageCount} 个图片文件`, 'info'); + } + } + + // 复制本地字体 + const localFonts = fonts.filter(f => !f.isCDN); + if (localFonts.length > 0) { + const fontsPath = path.join(assetsPath, 'fonts'); + if (fs.existsSync(fontsPath)) { + const fontCount = copyDirectory(fontsPath, path.join(outputAssetsPath, 'fonts')); + log(` ✓ 复制了 ${fontCount} 个字体文件`, 'info'); + } + } + + // 统计 CDN 字体 + const cdnFonts = fonts.filter(f => f.isCDN); + if (cdnFonts.length > 0) { + log(` ✓ 保留了 ${cdnFonts.length} 个 CDN 字体链接`, 'info'); + } + } + + // 复制参考文件(如果存在) + const filesToCopy = ['screenshot.png', 'content.md', 'theme.json']; + filesToCopy.forEach(filename => { + const srcFile = path.join(sourcePath, filename); + if (fs.existsSync(srcFile)) { + const destFile = path.join(outputDir, filename); + fs.copyFileSync(srcFile, destFile); + log(` ✓ 复制了 ${filename}`, 'info'); + } + }); + + log(`页面转换完成: ${pageSlug}`, 'info'); +} + +/** + * 检测项目类型(仅支持 Chrome 扩展导出) + */ +function detectProjectType(sourcePath) { + const items = fs.readdirSync(sourcePath); + + // 检查是否为 Chrome 扩展导出格式(有 index.html) + if (items.includes('index.html')) { + return { type: 'chrome-export', prototypes: [{ name: 'index', path: sourcePath }] }; + } + + throw new Error('未找到 index.html 文件,请确认这是 Chrome 扩展导出的项目'); +} + +/** + * 主函数 + */ +async function main() { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === '--help') { + console.log(` +Chrome 扩展导出转换器 + +使用方法: + node scripts/chrome-export-converter.mjs [output-name] [display-name] + node scripts/chrome-export-converter.mjs --name --display-name --target-dir + +参数说明: + source-dir : Chrome 扩展导出的目录(包含 index.html) + output-name : 输出页面名称(可选,默认使用目录名) + display-name : 页面显示名(可选,写入 index.tsx 的 @name) + target-dir : 输出到 src/prototypes 下的相对目录(可选) + +示例: + node scripts/chrome-export-converter.mjs ".drafts/my-export" my-page + node scripts/chrome-export-converter.mjs ".drafts/my-export" my-page "登录页" + node scripts/chrome-export-converter.mjs ".drafts/my-export" --name my-page --target-dir grouped/login-page + `); + process.exit(0); + } + + const flags = {}; + const positionals = []; + for (let i = 0; i < args.length; i += 1) { + const token = args[i]; + if (token === '--name' || token === '--display-name' || token === '--target-dir') { + const next = args[i + 1]; + if (typeof next === 'string' && next) { + flags[token] = next; + i += 1; + } else { + flags[token] = ''; + } + continue; + } + positionals.push(token); + } + + const sourceDirArg = positionals[0]; + const outputNameRaw = flags['--name'] || positionals[1] || path.basename(sourceDirArg); + const outputName = String(outputNameRaw) + .replace(/[^a-z0-9-]/gi, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .toLowerCase(); + const displayNameRaw = flags['--display-name'] ?? positionals[2]; + const displayName = (displayNameRaw !== undefined ? String(displayNameRaw).trim() : '') || outputName; + if (displayNameRaw !== undefined) { + const trimmedDisplayName = String(displayNameRaw).trim(); + if (!trimmedDisplayName || trimmedDisplayName.length > 200) { + throw new Error('displayName 长度必须在 1-200 字符'); + } + } + + const requestedTargetDir = normalizeRelativeDir(flags['--target-dir'] || outputName); + if (!isSafeRelativeDir(requestedTargetDir)) { + throw new Error('target-dir 必须是 src/prototypes 下的安全相对路径'); + } + + const sourcePath = path.resolve(CONFIG.projectRoot, sourceDirArg); + const outputDir = path.resolve(CONFIG.pagesDir, requestedTargetDir); + const resolvedPagesDir = path.resolve(CONFIG.pagesDir); + if (outputDir === resolvedPagesDir || !outputDir.startsWith(`${resolvedPagesDir}${path.sep}`)) { + throw new Error('target-dir 超出 src/prototypes 目录范围'); + } + + if (!fs.existsSync(sourcePath)) { + log(`错误: 找不到目录 ${sourcePath}`, 'error'); + process.exit(1); + } + + try { + log('开始转换 Chrome 扩展导出...', 'info'); + + const { type, prototypes } = detectProjectType(sourcePath); + log(`项目类型: ${type}`, 'info'); + + convertPage(prototypes[0].path, outputDir, outputName, displayName); + log('✅ 转换完成!', 'info'); + log(`📁 页面位置: ${outputDir}`, 'info'); + + } catch (error) { + log(`转换失败: ${error.message}`, 'error'); + console.error(error); + process.exit(1); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + main(); +} + +export { + convertCommonAttributesToJSX, + convertHtmlToJSX, + convertStyleToJSX, + extractBodyContent, + generateComponent, + reconcileImageAssetReferences, +}; diff --git a/scripts/convert-v266-contract.py b/scripts/convert-v266-contract.py new file mode 100644 index 0000000..ddc687a --- /dev/null +++ b/scripts/convert-v266-contract.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Convert textutil Word HTML export to ct-word-doc seed for ContractTemplate.""" + +import json +import re +from pathlib import Path +from typing import Dict, List, Optional, Set + +from bs4 import BeautifulSoup, NavigableString, Tag + +SRC = Path("/tmp/v266-contract.html") +OUT = Path(__file__).resolve().parent.parent / "src/prototypes/contract-template-management/v266-lease-document.js" + +# Semantic ct-word classes for key blocks (layout still driven by embedded CSS) +SEMANTIC = { + "p2": "ct-word-title", + "p3": "ct-word-contract-no", + "p5": "ct-word-party-line", + "p6": "ct-word-party-line", + "p7": "ct-word-party-line", + "p8": "ct-word-party-line", +} + + +def parse_css_rules(css_text: str) -> Dict[str, str]: + rules: Dict[str, str] = {} + for block in re.findall(r"([^{]+)\{([^}]+)\}", css_text): + selector = block[0].strip() + props = block[1].strip() + if "," in selector: + continue + selector = selector.lstrip(".") + if re.match(r"^(p|span|td|table)\.", selector): + rules[selector] = props + return rules + + +def is_red_style(style: str) -> bool: + return bool(re.search(r"color\s*:\s*#ff0000", style, re.I)) + + +def build_red_classes(rules: Dict[str, str]) -> Set[str]: + red = set() + for cls, props in rules.items(): + if is_red_style(props): + red.add(cls.split(".")[-1]) + return red + + +def class_list(tag: Tag) -> List[str]: + raw = tag.get("class") or [] + if isinstance(raw, str): + return raw.split() + return list(raw) + + +def primary_class(tag: Tag) -> Optional[str]: + classes = class_list(tag) + for c in classes: + if c in SEMANTIC or re.match(r"^[pst]\d+$", c) or re.match(r"^td\d+$", c): + return c + return classes[0] if classes else None + + +def merge_style(tag: Tag, rules: Dict[str, str]) -> None: + parts: List[str] = [] + for c in class_list(tag): + key_p = f"p.{c}" if c.startswith("p") else None + key_s = f"span.{c}" if c.startswith("s") else None + key_td = f"td.{c}" if c.startswith("td") else None + key_t = f"table.{c}" if c.startswith("t") else None + for key in (key_p, key_s, key_td, key_t): + if key and key in rules: + parts.append(rules[key]) + if parts: + existing = tag.get("style", "") + merged = ";".join([existing] + parts) if existing else ";".join(parts) + tag["style"] = merged + + +def strip_apple_noise(soup: BeautifulSoup) -> None: + for el in soup.find_all(class_="Apple-converted-space"): + el.replace_with("\u00a0" * max(1, len(el.get_text()))) + for el in soup.find_all(class_="Apple-tab-span"): + el.replace_with("\t") + + +def wrap_risk_redlines(soup: BeautifulSoup, red_classes: Set[str]) -> None: + risk_seq = 0 + + def next_id() -> str: + nonlocal risk_seq + risk_seq += 1 + return f"risk-{risk_seq}" + + def is_red_tag(tag: Tag) -> bool: + pc = primary_class(tag) + return pc in red_classes if pc else False + + # Block-level red paragraphs: wrap inner content once + for p in list(soup.find_all("p")): + if not is_red_tag(p): + continue + if p.find_parent(class_=lambda x: x and "ct-risk-redline" in x): + continue + inner = list(p.contents) + if not inner: + continue + wrapper = soup.new_tag( + "span", + attrs={ + "class": "ct-risk-redline", + "data-risk-redline": "1", + "data-risk-id": next_id(), + }, + ) + for child in inner: + wrapper.append(child.extract() if isinstance(child, Tag) else child) + p.clear() + p.append(wrapper) + + # Inline red spans inside non-red paragraphs + for span in list(soup.find_all("span")): + if not is_red_tag(span): + continue + if span.find_parent(class_=lambda x: x and "ct-risk-redline" in x): + continue + parent_p = span.find_parent("p") + if parent_p and is_red_tag(parent_p): + continue + wrapper = soup.new_tag( + "span", + attrs={ + "class": "ct-risk-redline", + "data-risk-redline": "1", + "data-risk-id": next_id(), + }, + ) + span.wrap(wrapper) + + +def apply_semantic_classes(root: Tag) -> None: + for tag in root.find_all(True): + pc = primary_class(tag) + if pc and pc in SEMANTIC: + classes = class_list(tag) + extra = SEMANTIC[pc] + if extra not in classes: + tag["class"] = classes + [extra] + + +def map_tables(root: Tag) -> None: + for i, table in enumerate(root.find_all("table")): + classes = class_list(table) + if "ct-word-table" not in classes: + table["class"] = classes + ["ct-doc-table", "ct-word-table"] + if i == 0: + for td in table.find_all("td"): + tdc = primary_class(td) + tr = td.find_parent("tr") + row_idx = len(list(tr.find_previous_siblings("tr"))) if tr else 0 + if row_idx == 0: + if tdc == "td1": + td["class"] = class_list(td) + ["ct-word-party-left"] + elif tdc == "td2": + td["class"] = class_list(td) + ["ct-word-party-right"] + else: + td["class"] = class_list(td) + ["ct-word-td"] + + +def apply_template_vars(html: str) -> str: + regex_replacements = [ + (r"合同编号]*>:【LNZLHT\s*]*>[\s\S]*?\s*】", + "合同编号:【LNZLHT {{contractCode}} 】"), + (r"合同编号:【LNZLHT[^】]*】", "合同编号:【LNZLHT {{contractCode}} 】"), + ] + literal_replacements = [ + ("甲方(出租方):羚牛氢能科技(广东)有限公司", "甲方(出租方):{{lessorName}}"), + ("甲方(出租方): 羚牛氢能科技(广东)有限公司", "甲方(出租方): {{lessorName}}"), + ("甲方(出租方): 羚牛氢能科技(广东)有限公司", "甲方(出租方): {{lessorName}}"), + ("甲方:羚牛氢能科技(广东)有限公司", "甲方:{{lessorName}}"), + ("致:羚牛氢能科技(广东)有限公司", "致:{{lessorName}}"), + ("户 名:【羚牛氢能科技(广东)有限公司", "户 名:【{{lessorAccountName}}"), + ("开户行:【招商银行广州萝岗支行 】", "开户行:【{{lessorBankName}} 】"), + ("账 号:【120924165110201 】", "账 号:【{{lessorBankAccount}} 】"), + ("乙方(承租方):", + "乙方(承租方):{{customerName}}"), + ("乙方(承租方):", "乙方(承租方): {{customerName}}"), + ("乙方(承租方):", "乙方(承租方):{{customerName}}"), + ] + out = html + for pattern, repl in regex_replacements: + out = re.sub(pattern, repl, out) + for old, new in literal_replacements: + out = out.replace(old, new) + return out + + +def scope_css(css_text: str) -> str: + scoped = [] + for block in re.findall(r"([^{]+)\{([^}]+)\}", css_text): + selector = block[0].strip() + props = block[1].strip() + if selector.startswith("@") or "," in selector: + continue + scoped.append(f".ct-word-doc--v266 {selector}{{{props}}}") + return "\n".join(scoped) + + +def convert() -> str: + raw = SRC.read_text(encoding="utf-8") + css_match = re.search(r"]*>([\s\S]*?)", raw) + body_match = re.search(r"([\s\S]*?)", raw) + if not css_match or not body_match: + raise SystemExit("Invalid source HTML") + + rules = parse_css_rules(css_match.group(1)) + red_classes = build_red_classes(rules) + scoped = scope_css(css_match.group(1)) + + soup = BeautifulSoup(body_match.group(1), "lxml") + # lxml adds html/body wrapper + root = soup.body or soup + strip_apple_noise(soup) + + for tag in soup.find_all(True): + merge_style(tag, rules) + + wrap_risk_redlines(soup, red_classes) + apply_semantic_classes(soup) + map_tables(soup) + + body_html = "".join(str(c) for c in (soup.body or soup).contents) + body_html = apply_template_vars(body_html) + body_html = re.sub(r"

\s*", "", body_html, count=1) + + doc = ( + '
' + f"" + f"{body_html}" + "
" + ) + return doc + + +def main() -> None: + html = convert() + escaped = json.dumps(html, ensure_ascii=False) + OUT.write_text( + "// AUTO-GENERATED V26.6 Word HTML — do not edit by hand\n" + f"export var V266_LEASE_DOCUMENT_HTML = {escaped};\n", + encoding="utf-8", + ) + print(f"Wrote {OUT} ({OUT.stat().st_size} bytes)") + print(f"Redline markers: {html.count('data-risk-redline')}") + + +if __name__ == "__main__": + main() diff --git a/scripts/convert-word-doc.py b/scripts/convert-word-doc.py new file mode 100644 index 0000000..f709d63 --- /dev/null +++ b/scripts/convert-word-doc.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Convert textutil Word HTML export to ct-word-doc seed JS module.""" + +import argparse +import json +import re +import subprocess +import tempfile +from pathlib import Path +from typing import Dict, List, Optional, Set + +from bs4 import BeautifulSoup, NavigableString, Tag + +DEFAULT_SEMANTIC = { + "p1": "ct-word-title", + "p2": "ct-word-contract-no", + "p3": "ct-word-contract-no", + "p5": "ct-word-party-line", + "p6": "ct-word-party-line", + "p7": "ct-word-party-line", + "p8": "ct-word-party-line", +} + + +def parse_css_rules(css_text: str) -> Dict[str, str]: + rules: Dict[str, str] = {} + for block in re.findall(r"([^{]+)\{([^}]+)\}", css_text): + selector = block[0].strip() + props = block[1].strip() + if "," in selector: + continue + selector = selector.lstrip(".") + if re.match(r"^(p|span|td|table)\.", selector): + rules[selector] = props + return rules + + +def is_red_style(style: str) -> bool: + return bool(re.search(r"color\s*:\s*#ff0000", style, re.I)) + + +def build_red_classes(rules: Dict[str, str]) -> Set[str]: + red = set() + for cls, props in rules.items(): + if is_red_style(props): + red.add(cls.split(".")[-1]) + return red + + +def class_list(tag: Tag) -> List[str]: + raw = tag.get("class") or [] + if isinstance(raw, str): + return raw.split() + return list(raw) + + +def primary_class(tag: Tag) -> Optional[str]: + classes = class_list(tag) + for c in classes: + if c in DEFAULT_SEMANTIC or re.match(r"^[pst]\d+$", c) or re.match(r"^td\d+$", c): + return c + return classes[0] if classes else None + + +def merge_style(tag: Tag, rules: Dict[str, str]) -> None: + parts: List[str] = [] + for c in class_list(tag): + key_p = f"p.{c}" if c.startswith("p") else None + key_s = f"span.{c}" if c.startswith("s") else None + key_td = f"td.{c}" if c.startswith("td") else None + key_t = f"table.{c}" if c.startswith("t") else None + for key in (key_p, key_s, key_td, key_t): + if key and key in rules: + parts.append(rules[key]) + if parts: + existing = tag.get("style", "") + merged = ";".join([existing] + parts) if existing else ";".join(parts) + tag["style"] = merged + + +def strip_apple_noise(soup: BeautifulSoup) -> None: + for el in soup.find_all(class_="Apple-converted-space"): + el.replace_with("\u00a0" * max(1, len(el.get_text()))) + for el in soup.find_all(class_="Apple-tab-span"): + el.replace_with("\t") + + +def wrap_risk_redlines(soup: BeautifulSoup, red_classes: Set[str]) -> None: + risk_seq = 0 + + def next_id() -> str: + nonlocal risk_seq + risk_seq += 1 + return f"risk-{risk_seq}" + + def is_red_tag(tag: Tag) -> bool: + pc = primary_class(tag) + return pc in red_classes if pc else False + + for p in list(soup.find_all("p")): + if not is_red_tag(p): + continue + if p.find_parent(class_=lambda x: x and "ct-risk-redline" in x): + continue + inner = list(p.contents) + if not inner: + continue + wrapper = soup.new_tag( + "span", + attrs={ + "class": "ct-risk-redline", + "data-risk-redline": "1", + "data-risk-id": next_id(), + }, + ) + for child in inner: + wrapper.append(child.extract() if isinstance(child, Tag) else child) + p.clear() + p.append(wrapper) + + for span in list(soup.find_all("span")): + if not is_red_tag(span): + continue + if span.find_parent(class_=lambda x: x and "ct-risk-redline" in x): + continue + parent_p = span.find_parent("p") + if parent_p and is_red_tag(parent_p): + continue + wrapper = soup.new_tag( + "span", + attrs={ + "class": "ct-risk-redline", + "data-risk-redline": "1", + "data-risk-id": next_id(), + }, + ) + span.wrap(wrapper) + + +def apply_semantic_classes(root: Tag, semantic: Dict[str, str]) -> None: + for tag in root.find_all(True): + pc = primary_class(tag) + if pc and pc in semantic: + classes = class_list(tag) + extra = semantic[pc] + if extra not in classes: + tag["class"] = classes + [extra] + + +def map_tables(root: Tag) -> None: + for i, table in enumerate(root.find_all("table")): + classes = class_list(table) + if "ct-word-table" not in classes: + table["class"] = classes + ["ct-doc-table", "ct-word-table"] + for td in table.find_all("td"): + td["class"] = class_list(td) + ["ct-word-td"] + + +def apply_template_vars(html: str) -> str: + regex_replacements = [ + (r"合同编号]*>:【LNZLHT\s*]*>[\s\S]*?\s*】", + "合同编号:【LNZLHT {{contractCode}} 】"), + (r"合同编号:【LNZLHT[^】]*】", "合同编号:【LNZLHT {{contractCode}} 】"), + (r"协议编号:【LNZLHT[^】]*】", "协议编号:【LNZLHT {{contractCode}} 】"), + ] + literal_replacements = [ + ("甲方(出租方):羚牛氢能科技(广东)有限公司", "甲方(出租方):{{lessorName}}"), + ("甲方(出租方): 羚牛氢能科技(广东)有限公司", "甲方(出租方): {{lessorName}}"), + ("甲方:羚牛氢能科技(广东)有限公司", "甲方:{{lessorName}}"), + ("致:羚牛氢能科技(广东)有限公司", "致:{{lessorName}}"), + ("甲方(车辆提供方):", "甲方(车辆提供方):{{lessorName}}"), + ("乙方(车辆使用方):", "乙方(车辆使用方):{{customerName}}"), + ("乙方(承租方):", "乙方(承租方): {{customerName}}"), + ("乙方(承租方):", "乙方(承租方):{{customerName}}"), + ] + out = html + for pattern, repl in regex_replacements: + out = re.sub(pattern, repl, out) + for old, new in literal_replacements: + out = out.replace(old, new) + return out + + +def scope_css(css_text: str) -> str: + scoped = [] + for block in re.findall(r"([^{]+)\{([^}]+)\}", css_text): + selector = block[0].strip() + props = block[1].strip() + if selector.startswith("@") or "," in selector: + continue + scoped.append(f".ct-word-doc--v266 {selector}{{{props}}}") + return "\n".join(scoped) + + +def convert_html(raw: str) -> str: + css_match = re.search(r"]*>([\s\S]*?)", raw) + body_match = re.search(r"([\s\S]*?)", raw) + if not css_match or not body_match: + raise SystemExit("Invalid source HTML") + + rules = parse_css_rules(css_match.group(1)) + red_classes = build_red_classes(rules) + scoped = scope_css(css_match.group(1)) + + soup = BeautifulSoup(body_match.group(1), "lxml") + strip_apple_noise(soup) + + for tag in soup.find_all(True): + merge_style(tag, rules) + + wrap_risk_redlines(soup, red_classes) + apply_semantic_classes(soup, DEFAULT_SEMANTIC) + map_tables(soup) + + body_html = "".join(str(c) for c in (soup.body or soup).contents) + body_html = apply_template_vars(body_html) + body_html = re.sub(r"

\s*", "", body_html, count=3) + + return ( + '
' + f"" + f"{body_html}" + "
" + ) + + +def export_docx(docx: Path, out: Path, export_name: str) -> None: + with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as tmp: + tmp_path = Path(tmp.name) + subprocess.run( + ["textutil", "-convert", "html", "-output", str(tmp_path), str(docx)], + check=True, + ) + raw = tmp_path.read_text(encoding="utf-8") + tmp_path.unlink(missing_ok=True) + html = convert_html(raw) + escaped = json.dumps(html, ensure_ascii=False) + out.write_text( + f"// AUTO-GENERATED from {docx.name} — do not edit by hand\n" + f"export var {export_name} = {escaped};\n", + encoding="utf-8", + ) + print(f"Wrote {out} ({out.stat().st_size} bytes), redlines={html.count('data-risk-redline')}") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("docx", type=Path) + parser.add_argument("out", type=Path) + parser.add_argument("export_name") + args = parser.parse_args() + export_docx(args.docx, args.out, args.export_name) + + +if __name__ == "__main__": + main() diff --git a/scripts/cursor-usage-snapshot.py b/scripts/cursor-usage-snapshot.py new file mode 100755 index 0000000..b12c87c --- /dev/null +++ b/scripts/cursor-usage-snapshot.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""本地用量快照:不消耗 Cursor Agent token,直接读官方 API。""" +from __future__ import annotations + +import datetime as dt +import json +import sqlite3 +import urllib.request +from collections import defaultdict + +STATE_DB = "/Users/sylvawong/Library/Application Support/Cursor/User/globalStorage/state.vscdb" +TZ = dt.timezone(dt.timedelta(hours=8)) + + +def token() -> str: + conn = sqlite3.connect(STATE_DB) + try: + row = conn.execute( + "SELECT value FROM ItemTable WHERE key='cursorAuth/accessToken'" + ).fetchone() + finally: + conn.close() + if not row: + raise SystemExit("未找到 Cursor accessToken,请先在 Cursor 登录。") + return row[0] + + +def post(path: str, body: dict | None = None) -> dict: + req = urllib.request.Request( + f"https://api2.cursor.sh/{path}", + data=json.dumps(body or {}).encode(), + headers={ + "Authorization": f"Bearer {token()}", + "Content-Type": "application/json", + "Connect-Protocol-Version": "1", + "User-Agent": "oneos-token-budget/1.0", + }, + method="POST", + ) + with urllib.request.urlopen(req, timeout=60) as resp: + return json.loads(resp.read().decode() or "{}") + + +def ms_to_local(ms: str | int) -> str: + return dt.datetime.fromtimestamp(int(ms) / 1000, TZ).strftime("%Y-%m-%d %H:%M") + + +def main() -> None: + usage = post("aiserver.v1.DashboardService/GetCurrentPeriodUsage") + plan = post("aiserver.v1.DashboardService/GetPlanInfo") + pu = usage.get("planUsage") or {} + info = plan.get("planInfo") or {} + + print("=== Cursor 用量快照 ===") + print(f"套餐: {info.get('planName')} ({info.get('price')})") + print(f"账期: {ms_to_local(usage.get('billingCycleStart'))} → {ms_to_local(usage.get('billingCycleEnd'))}") + print(f"Auto+Composer: {float(pu.get('autoPercentUsed') or 0):.2f}%") + print(f"API: {float(pu.get('apiPercentUsed') or 0):.2f}%") + print(f"综合 total: {float(pu.get('totalPercentUsed') or 0):.2f}%") + print( + f"等价花费: ${float(pu.get('totalSpend') or 0)/100:.2f} / ${float(pu.get('limit') or 0)/100:.2f}" + ) + + # Today since 00:00 + now = dt.datetime.now(TZ) + start = now.replace(hour=0, minute=0, second=0, microsecond=0) + data = post( + "aiserver.v1.DashboardService/GetFilteredUsageEvents", + { + "startDate": str(int(start.timestamp() * 1000)), + "endDate": str(int(now.timestamp() * 1000)), + "page": 1, + "pageSize": 200, + }, + ) + events = list(data.get("usageEventsDisplay") or []) + total = int(data.get("totalUsageEventsCount") or 0) + pages = max(1, (total + 199) // 200) + for page in range(2, pages + 1): + more = post( + "aiserver.v1.DashboardService/GetFilteredUsageEvents", + { + "startDate": str(int(start.timestamp() * 1000)), + "endDate": str(int(now.timestamp() * 1000)), + "page": page, + "pageSize": 200, + }, + ) + events.extend(more.get("usageEventsDisplay") or []) + + tot = defaultdict(int) + by_model = defaultdict(lambda: defaultdict(int)) + for e in events: + tu = e.get("tokenUsage") or {} + inp = int(tu.get("inputTokens") or 0) + out = int(tu.get("outputTokens") or 0) + cache = int(tu.get("cacheReadTokens") or 0) + model = e.get("model") or "unknown" + tot["events"] += 1 + tot["input"] += inp + tot["output"] += out + tot["cache"] += cache + tot["all"] += inp + out + cache + tot["io"] += inp + out + by_model[model]["all"] += inp + out + cache + by_model[model]["events"] += 1 + + print(f"\n=== 今日(自 {start.strftime('%H:%M')})===") + print(f"请求: {tot['events']:,}") + print(f"输入+输出: {tot['io']:,}") + print(f"含缓存合计: {tot['all']:,}") + print("按模型:") + for model, b in sorted(by_model.items(), key=lambda kv: -kv[1]["all"]): + print(f" - {model}: {b['events']} 次, 含缓存 {b['all']:,}") + + +if __name__ == "__main__": + main() diff --git a/scripts/enrich-prototype-registry-from-git.mjs b/scripts/enrich-prototype-registry-from-git.mjs new file mode 100644 index 0000000..bc45d49 --- /dev/null +++ b/scripts/enrich-prototype-registry-from-git.mjs @@ -0,0 +1,339 @@ +/** + * 从 Git 提交历史生成/补全原型导航注册表中的详细变更日志。 + * + * 用法: + * node scripts/enrich-prototype-registry-from-git.mjs + * node scripts/enrich-prototype-registry-from-git.mjs --prototype vehicle-h2-fee-ledger + */ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, '..'); +const REGISTRY_PATH = path.join(projectRoot, 'src/prototypes/oneos-prototype-nav/prototype-registry.json'); +const TRACKED_EXTENSIONS = new Set(['.tsx', '.ts', '.jsx', '.js', '.css', '.json', '.md', '.html']); +const IGNORED_FILE_NAMES = new Set(['prototype-registry.json', 'nav-menu.json']); +const MAX_CHANGELOG = 30; +const MAX_RECENT = 40; + +function parseArgs(argv) { + const args = { prototype: '' }; + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === '--prototype' && argv[i + 1]) args.prototype = argv[++i].trim(); + } + return args; +} + +function shouldTrackFile(relativePath) { + const base = path.basename(relativePath); + if (IGNORED_FILE_NAMES.has(base)) return false; + if (relativePath === 'annotation-source.json') return false; + return TRACKED_EXTENSIONS.has(path.extname(relativePath).toLowerCase()); +} + +function normalizePrototypePath(filePath, prototypeId) { + const normalized = String(filePath || '').replace(/\\/gu, '/').trim(); + const prefix = `src/prototypes/${prototypeId}/`; + if (normalized.startsWith(prefix)) return normalized.slice(prefix.length); + return null; +} + +function parseCommitBlocks(raw) { + const blocks = raw.split(/\n?----\n/u).map((block) => block.trim()).filter(Boolean); + const commits = []; + for (const block of blocks) { + const lines = block.split('\n'); + const hash = lines[0]?.trim(); + if (!/^[0-9a-f]{7,40}$/iu.test(hash || '')) continue; + const dateLine = lines[1]?.trim(); + const subject = lines[2]?.trim() || ''; + if (!dateLine) continue; + + const fileStart = lines.findIndex((line, index) => index > 2 && line.trim() === ''); + const bodyEnd = fileStart >= 0 ? fileStart : lines.length; + const body = lines.slice(3, bodyEnd).join('\n').trim(); + const files = (fileStart >= 0 ? lines.slice(fileStart + 1) : []) + .map((line) => line.trim()) + .filter(Boolean); + + commits.push({ hash, dateLine, subject, body, files }); + } + return commits; +} + +function getPrototypeCommits(prototypeId) { + const relDir = `src/prototypes/${prototypeId}`; + if (!fs.existsSync(path.join(projectRoot, relDir))) return []; + let raw = ''; + try { + raw = execSync( + `git log --pretty=format:----%n%H%n%ci%n%s%n%b --name-only -- ${relDir}`, + { cwd: projectRoot, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 }, + ); + } catch { + return []; + } + return parseCommitBlocks(raw); +} + +function formatDateParts(dateLine) { + const match = dateLine.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})/u); + if (!match) { + const date = new Date(dateLine); + if (Number.isNaN(date.getTime())) return { date: '1970-01-01', time: '00:00', iso: null }; + return { + date: date.toISOString().slice(0, 10), + time: `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`, + iso: date.toISOString(), + }; + } + const [, y, m, d, hh, mm] = match; + return { + date: `${y}-${m}-${d}`, + time: `${hh}:${mm}`, + iso: new Date(`${y}-${m}-${d}T${hh}:${mm}:00+08:00`).toISOString(), + }; +} + +function extractBodyLines(body) { + return body + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !/^Co-authored-by:/iu.test(line)) + .map((line) => line.replace(/^[-*•]\s*/u, '').trim()) + .filter(Boolean); +} + +function strictKeywords(title, prototypeId) { + const keywords = new Set([title, prototypeId]); + for (const part of String(title || '').split(/[·、/()()\s「」]+/u)) { + const trimmed = part.trim(); + if (trimmed.length >= 3) keywords.add(trimmed); + } + if (title.includes('氢费明细')) keywords.add('氢费明细'); + else if (title.includes('氢费')) keywords.add('氢费'); + if (title.includes('原型导航')) keywords.add('原型导航'); + if (title.includes('合同模板')) keywords.add('合同模板'); + if (title.includes('租赁合同')) keywords.add('租赁合同'); + if (title.includes('租赁业务')) keywords.add('租赁业务'); + if (title.includes('收款记录')) keywords.add('收款记录'); + if (title.includes('维修保养')) keywords.add('维修保养'); + if (title.includes('还车应结')) keywords.add('还车应结'); + return [...keywords]; +} + +function lineMatchesPrototype(line, title, prototypeId) { + if (!line) return false; + if (line.includes(title)) return true; + if (line.toLowerCase().includes(prototypeId.toLowerCase())) return true; + return strictKeywords(title, prototypeId).some((keyword) => ( + keyword.length >= 3 && line.includes(keyword) + )); +} + +function commitIsRelevant(commit, title, prototypeId, changedFiles, allChangedFiles, bodyLines) { + if (changedFiles.length > 0) return true; + + const onlyAnnotation = allChangedFiles.length > 0 + && allChangedFiles.every((file) => file === 'annotation-source.json'); + const textMatches = [commit.subject, ...bodyLines].some((line) => ( + lineMatchesPrototype(line, title, prototypeId) + )); + + if (textMatches && onlyAnnotation) return true; + if (textMatches) return true; + return false; +} + +function summarizeFileChanges(files) { + const notes = []; + const prd = files.filter((f) => f.includes('.spec/') && f.endsWith('.md')); + const comments = files.filter((f) => f.includes('prototype-comments') || f.includes('annotation-source')); + const styles = files.filter((f) => f.endsWith('.css')); + const logic = files.filter((f) => /\.(tsx|jsx|ts|js)$/u.test(f) && !f.endsWith('.css')); + const data = files.filter((f) => f.includes('/data/') || f.endsWith('.json')); + + if (logic.length) { + const names = [...new Set(logic.map((f) => path.basename(f)))].slice(0, 4); + notes.push(`页面逻辑与交互:${names.join('、')}${logic.length > names.length ? ' 等' : ''}`); + } + if (styles.length) notes.push(`样式与布局:${[...new Set(styles.map((f) => path.basename(f)))].join('、')}`); + if (prd.length) notes.push(`需求说明与 PRD:${prd.map((f) => path.basename(f)).join('、')}`); + if (comments.length) notes.push('原型标注目录与批注说明同步'); + if (data.length) notes.push(`演示数据与配置:${data.map((f) => path.basename(f)).join('、')}`); + return notes; +} + +function buildSummary(subject, prototypeId, title, bodyLines, changedFiles, onlyAnnotation) { + const matched = bodyLines.filter((line) => lineMatchesPrototype(line, title, prototypeId)); + if (matched.length === 1) return matched[0]; + if (matched.length > 1) return matched[0]; + + const fragments = splitSubjectFragments(subject, prototypeId, title); + if (fragments.length === 1) return fragments[0]; + if (fragments.length > 1) return fragments[0]; + + if (onlyAnnotation && changedFiles.length === 0) return '原型标注目录同步'; + if (changedFiles.length) return summarizeFileChanges(changedFiles)[0] || subject; + if (lineMatchesPrototype(subject, title, prototypeId)) return subject; + + return subject; +} + +function dedupeDetails(summary, details) { + return [...new Set(details)].filter((line) => ( + line + && line !== summary + && !summary.includes(line) + && line.length > 2 + )); +} + +function splitSubjectFragments(subject, prototypeId, title) { + const segments = subject + .split(/[;;]/u) + .map((part) => part.trim()) + .filter(Boolean); + const matched = segments.filter((segment) => lineMatchesPrototype(segment, title, prototypeId)); + if (!matched.length) return []; + + const fragments = []; + for (const segment of matched) { + const colonIndex = segment.indexOf(':'); + const tail = colonIndex >= 0 ? segment.slice(colonIndex + 1) : segment; + tail.split(/[、,,]/u) + .map((part) => part.trim()) + .filter((part) => part.length >= 4) + .forEach((part) => fragments.push(part)); + } + return [...new Set(fragments)]; +} + +function buildDetails(subject, bodyLines, prototypeId, title, changedFiles) { + const details = []; + const matched = bodyLines.filter((line) => lineMatchesPrototype(line, title, prototypeId)); + details.push(...matched); + + for (const fragment of splitSubjectFragments(subject, prototypeId, title)) { + if (!details.includes(fragment) && fragment !== subject) details.push(fragment); + } + + for (const note of summarizeFileChanges(changedFiles)) { + if (!details.includes(note)) details.push(note); + } + + if (!details.length && changedFiles.length) { + details.push(`变更 ${changedFiles.length} 个文件`); + } + + return details.filter((line) => line !== subject); +} + +function bumpVersion(indexFromOldest) { + return `v1.${indexFromOldest}`; +} + +function buildChangelogFromGit(prototypeId, title) { + const commits = getPrototypeCommits(prototypeId); + if (!commits.length) return []; + + const chronological = [...commits].reverse(); + const entries = []; + + for (const commit of chronological) { + const { date, time } = formatDateParts(commit.dateLine); + const bodyLines = extractBodyLines(commit.body); + const changedFiles = commit.files + .map((file) => normalizePrototypePath(file, prototypeId)) + .filter((file) => file && shouldTrackFile(file)); + const allChangedFiles = commit.files + .map((file) => normalizePrototypePath(file, prototypeId)) + .filter(Boolean); + const onlyAnnotation = allChangedFiles.length > 0 + && allChangedFiles.every((file) => file === 'annotation-source.json'); + + if (!commitIsRelevant(commit, title, prototypeId, changedFiles, allChangedFiles, bodyLines)) { + continue; + } + + const summary = buildSummary(commit.subject, prototypeId, title, bodyLines, changedFiles, onlyAnnotation); + const details = dedupeDetails( + summary, + buildDetails(commit.subject, bodyLines, prototypeId, title, changedFiles), + ); + + entries.push({ + version: '', + date, + time, + summary, + details, + commit: commit.hash.slice(0, 7), + files: [...new Set(changedFiles)].slice(0, 12), + }); + } + + return entries.reverse().map((entry, index, list) => ({ + ...entry, + version: bumpVersion(list.length - index), + })); +} + +function rebuildRecentUpdates(registry) { + const rows = []; + for (const [prototypeId, record] of Object.entries(registry.prototypes || {})) { + const latest = record.changelog?.[0]; + if (!latest) continue; + rows.push({ + prototypeId, + title: record.title || prototypeId, + version: latest.version, + date: latest.date, + time: latest.time, + summary: latest.summary, + details: latest.details || [], + files: latest.files || [], + commit: latest.commit, + _sort: `${latest.date}T${latest.time}`, + }); + } + rows.sort((a, b) => b._sort.localeCompare(a._sort)); + return rows.slice(0, MAX_RECENT).map(({ _sort, ...row }) => row); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const registry = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf8')); + const prototypeIds = args.prototype + ? [args.prototype] + : Object.keys(registry.prototypes || {}); + + let updated = 0; + for (const prototypeId of prototypeIds) { + const record = registry.prototypes[prototypeId]; + if (!record) continue; + const changelog = buildChangelogFromGit(prototypeId, record.title || prototypeId); + if (!changelog.length) continue; + + record.changelog = changelog.slice(0, MAX_CHANGELOG); + record.revision = changelog.length; + record.version = changelog[0].version; + const latestIso = getPrototypeCommits(prototypeId)[0]; + if (latestIso) { + const { iso } = formatDateParts(latestIso.dateLine); + if (iso) record.lastUpdated = iso; + } + registry.prototypes[prototypeId] = record; + updated += 1; + console.log(`[git] ${prototypeId}: ${changelog.length} entries → ${changelog[0].version}`); + } + + registry.recentUpdates = rebuildRecentUpdates(registry); + registry.updatedAt = new Date().toISOString(); + fs.writeFileSync(REGISTRY_PATH, `${JSON.stringify(registry, null, 2)}\n`, 'utf8'); + console.log(`完成:补全 ${updated} 个原型的 Git 变更日志`); +} + +main(); diff --git a/scripts/figma-make-converter.mjs b/scripts/figma-make-converter.mjs new file mode 100644 index 0000000..1b3576e --- /dev/null +++ b/scripts/figma-make-converter.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; + +const TASK_FILE_BY_TARGET = { + prototypes: '.figma-make-tasks.md', + components: '.figma-make-tasks.md', + themes: '.figma-make-theme-tasks.md', +}; + +function normalizeSlashes(input) { + return String(input || '').replace(/\\/g, '/'); +} + +function sanitizeName(rawName) { + return String(rawName || '') + .replace(/[^a-z0-9-]/gi, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .toLowerCase(); +} + +function parseArgs(argv) { + const args = [...argv]; + const projectDirArg = args.shift(); + const outputNameArg = args.shift(); + let targetType = 'prototypes'; + let projectRoot = process.cwd(); + let outputBaseDir = ''; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--target-type') { + targetType = String(args[index + 1] || '').trim(); + index += 1; + } else if (arg === '--project-root') { + projectRoot = path.resolve(args[index + 1] || projectRoot); + index += 1; + } else if (arg === '--output-base-dir') { + outputBaseDir = path.resolve(args[index + 1] || ''); + index += 1; + } + } + + if (!projectDirArg) { + throw new Error('Missing project directory'); + } + if (!TASK_FILE_BY_TARGET[targetType]) { + throw new Error(`Unsupported targetType: ${targetType}`); + } + const outputName = sanitizeName(outputNameArg || path.basename(projectDirArg)); + if (!outputName) { + throw new Error('Missing valid output name'); + } + + return { + projectDir: path.resolve(projectRoot, projectDirArg), + outputName, + targetType, + projectRoot, + outputBaseDir: outputBaseDir || path.resolve(projectRoot, 'src', targetType), + }; +} + +function copyDirectory(src, dest) { + if (!fs.existsSync(src)) return 0; + fs.mkdirSync(dest, { recursive: true }); + let count = 0; + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === '.npm-local-cache' || entry.name === 'build') { + continue; + } + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) { + count += copyDirectory(srcPath, destPath); + } else if (entry.isFile()) { + fs.copyFileSync(srcPath, destPath); + count += 1; + } + } + return count; +} + +function ensureIndex(outputDir) { + const indexPath = path.join(outputDir, 'index.tsx'); + if (!fs.existsSync(indexPath)) { + fs.writeFileSync(indexPath, [ + "import React from 'react';", + '', + 'export default function ImportedFigmaMakePrototype() {', + ' return
Figma Make import requires AI conversion.
;', + '}', + '', + ].join('\n'), 'utf8'); + } +} + +function writeTaskFile(params) { + const taskFileName = TASK_FILE_BY_TARGET[params.targetType]; + const taskPath = path.join(params.outputDir, taskFileName); + const relativeOutput = normalizeSlashes(path.relative(params.projectRoot, params.outputDir)); + const sourceContext = normalizeSlashes(path.relative(params.projectRoot, params.projectDir)); + const content = [ + '# Figma Make 项目转换任务清单', + '', + '> 请先阅读 `rules/development-guide.md`、`rules/design-guide.md` 和 `rules/default-resource-recommendations.md`,再基于该目录完成原型转换。', + '', + `- 输出目录:\`${relativeOutput}/\``, + `- 上传上下文:\`${sourceContext}/\``, + `- 已复制文件数:${params.fileCount}`, + '', + '## 执行要求', + '- 保留原始 Figma Make 项目结构与素材。', + '- 将可运行原型入口整理到 `index.tsx`。', + '- 如已有 `src/App.tsx`,优先复用其页面结构。', + '', + ].join('\n'); + fs.writeFileSync(taskPath, content, 'utf8'); + return taskPath; +} + +function main() { + const parsed = parseArgs(process.argv.slice(2)); + if (!fs.existsSync(path.join(parsed.projectDir, 'src')) || !fs.existsSync(path.join(parsed.projectDir, 'package.json'))) { + throw new Error('这不是一个有效的 Figma Make 项目(需要包含 src/ 和 package.json)'); + } + const outputDir = path.join(parsed.outputBaseDir, parsed.outputName); + fs.rmSync(outputDir, { recursive: true, force: true }); + fs.mkdirSync(parsed.outputBaseDir, { recursive: true }); + const fileCount = copyDirectory(parsed.projectDir, outputDir); + ensureIndex(outputDir); + const taskPath = writeTaskFile({ ...parsed, outputDir, fileCount }); + console.log(JSON.stringify({ + success: true, + outputDir, + tasksFile: normalizeSlashes(path.relative(parsed.projectRoot, taskPath)), + })); +} + +try { + main(); +} catch (error) { + console.error(error?.message || String(error)); + process.exit(1); +} diff --git a/scripts/lease-contract-theme-skins.mjs b/scripts/lease-contract-theme-skins.mjs new file mode 100644 index 0000000..8a45166 --- /dev/null +++ b/scripts/lease-contract-theme-skins.mjs @@ -0,0 +1,14 @@ +/** + * 租赁合同主题比稿:兼容旧 import 路径,实现见 design-system/theme-variants。 + */ +export { + THEME_SHOTS, + THEME_CATALOG, + OFFICIAL_THEME, + buildSkinCss, + buildAntThemeConfig, + buildOfficialAntTheme, + getThemeById, + resolvePalette, + resolvePrimary, +} from '../src/resources/design-system/theme-variants/build-skin.mjs'; diff --git a/scripts/merge-registry-sidebar-conflicts.mjs b/scripts/merge-registry-sidebar-conflicts.mjs new file mode 100644 index 0000000..8074e8e --- /dev/null +++ b/scripts/merge-registry-sidebar-conflicts.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +/** + * 合并 merge 冲突中的 prototype-registry.json 与 sidebar-tree.json(保留两侧内容)。 + * 用法:node scripts/merge-registry-sidebar-conflicts.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, '..'); + +function gitShow(stage, filePath) { + return execSync(`git show :${stage}:${filePath}`, { + cwd: projectRoot, + encoding: 'utf8', + }); +} + +function readJsonFromGit(stage, relativePath) { + return JSON.parse(gitShow(stage, relativePath)); +} + +function writeJson(relativePath, value) { + const abs = path.join(projectRoot, relativePath); + fs.writeFileSync(abs, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function collectItemKeys(items, acc = new Set()) { + for (const item of items || []) { + if (item?.itemKey) acc.add(item.itemKey); + if (Array.isArray(item?.children)) collectItemKeys(item.children, acc); + } + return acc; +} + +function findItemByKey(items, itemKey) { + for (const item of items || []) { + if (item?.itemKey === itemKey) return item; + if (Array.isArray(item?.children)) { + const found = findItemByKey(item.children, itemKey); + if (found) return found; + } + } + return null; +} + +function findFolder(items, title) { + for (const item of items || []) { + if (item?.kind === 'folder' && item?.title === title) return item; + if (Array.isArray(item?.children)) { + const found = findFolder(item.children, title); + if (found) return found; + } + } + return null; +} + +function ensureChildFolder(parent, folderTitle, folderId) { + parent.children = parent.children || []; + let folder = parent.children.find((c) => c.kind === 'folder' && c.title === folderTitle); + if (!folder) { + folder = { + id: folderId, + kind: 'folder', + title: folderTitle, + children: [], + }; + parent.children.push(folder); + } + folder.children = folder.children || []; + return folder; +} + +function ensureItem(folder, item) { + if (!item?.itemKey) return; + const exists = folder.children.some((c) => c.itemKey === item.itemKey); + if (!exists) folder.children.push(JSON.parse(JSON.stringify(item))); +} + +function mergeRegistry() { + const relativePath = 'src/prototypes/oneos-prototype-nav/prototype-registry.json'; + const ours = readJsonFromGit(2, relativePath); + const theirs = readJsonFromGit(3, relativePath); + + const merged = { + version: 1, + updatedAt: new Date().toISOString(), + prototypes: { ...theirs.prototypes }, + recentUpdates: [], + }; + + for (const key of ['oneos-web-h2-station-weekly', 'oneos-web-h2-station-analysis']) { + if (ours.prototypes[key]) merged.prototypes[key] = ours.prototypes[key]; + } + + const seen = new Set(); + const combinedRecent = [...(ours.recentUpdates || []), ...(theirs.recentUpdates || [])]; + for (const entry of combinedRecent) { + const sig = `${entry.prototypeId}|${entry.version}|${entry.date}|${entry.time}`; + if (seen.has(sig)) continue; + seen.add(sig); + merged.recentUpdates.push(entry); + } + merged.recentUpdates = merged.recentUpdates.slice(0, 40); + + writeJson(relativePath, merged); + return { + prototypeCount: Object.keys(merged.prototypes).length, + recentCount: merged.recentUpdates.length, + hasXll: Boolean(merged.prototypes['xll-miniapp']), + h2AnalysisVersion: merged.prototypes['oneos-web-h2-station-analysis']?.version, + }; +} + +function mergeSidebar() { + const relativePath = '.axhub/make/sidebar-tree.json'; + const ours = readJsonFromGit(2, relativePath); + const theirs = readJsonFromGit(3, relativePath); + + const merged = JSON.parse(JSON.stringify(theirs)); + merged.updatedAt = new Date().toISOString(); + + const oursKeys = collectItemKeys(ours.prototypes); + const mergedKeys = collectItemKeys(merged.prototypes); + const localOnlyKeys = [ + 'prototypes/oneos-web-h2-station-weekly', + 'prototypes/oneos-web-h2-station-analysis', + ].filter((key) => oursKeys.has(key) && !mergedKeys.has(key)); + + const oneOsFolder = findFolder(merged.prototypes, 'OneOS'); + const h2Folder = oneOsFolder ? findFolder(oneOsFolder.children, '加氢站管理') : findFolder(merged.prototypes, '加氢站管理'); + + if (h2Folder) { + for (const itemKey of localOnlyKeys) { + const source = + findItemByKey(ours.prototypes, itemKey) || + ({ + 'prototypes/oneos-web-h2-station-weekly': { + id: 'item:prototypes:oneos-web-h2-station-weekly', + kind: 'item', + title: '站点周报统计', + itemKey: 'prototypes/oneos-web-h2-station-weekly', + }, + 'prototypes/oneos-web-h2-station-analysis': { + id: 'item-prototypes-oneos-web-h2-station-analysis', + kind: 'item', + title: '加氢站分析', + itemKey: 'prototypes/oneos-web-h2-station-analysis', + }, + }[itemKey]); + ensureItem(h2Folder, source); + } + } + + writeJson(relativePath, merged); + return { + addedKeys: localOnlyKeys, + itemCount: collectItemKeys(merged.prototypes).size, + }; +} + +const registry = mergeRegistry(); +const sidebar = mergeSidebar(); +console.log('[merge] prototype-registry.json', registry); +console.log('[merge] sidebar-tree.json', sidebar); diff --git a/scripts/migrate-prototype-annotation-host.mjs b/scripts/migrate-prototype-annotation-host.mjs new file mode 100644 index 0000000..3f9f44b --- /dev/null +++ b/scripts/migrate-prototype-annotation-host.mjs @@ -0,0 +1,62 @@ +/** + * 将原型 index.tsx 中的 AnnotationViewer 迁移为 PrototypeAnnotationHost。 + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, '..'); +const prototypesRoot = path.join(projectRoot, 'src/prototypes'); +const hostImport = "import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';"; + +function listIndexFiles() { + return fs.readdirSync(prototypesRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(prototypesRoot, entry.name, 'index.tsx')) + .filter((filePath) => fs.existsSync(filePath)) + .filter((filePath) => fs.readFileSync(filePath, 'utf8').includes('AnnotationViewer')); +} + +function migrateFile(filePath) { + let content = fs.readFileSync(filePath, 'utf8'); + if (!content.includes('AnnotationViewer')) return false; + if (content.includes('PrototypeAnnotationHost')) { + console.log(`[skip] ${path.relative(projectRoot, filePath)}`); + return false; + } + + content = content.replace( + /import\s*\{([^}]*)\}\s*from\s*'@axhub\/annotation';/u, + (match, importsBlock) => { + const cleaned = importsBlock + .split(',') + .map((item) => item.trim()) + .filter((item) => item && item !== 'AnnotationViewer') + .join(',\n '); + return `import {\n ${cleaned}\n} from '@axhub/annotation';`; + }, + ); + + if (!content.includes(hostImport)) { + content = content.replace( + /\} from '@axhub\/annotation';/u, + `} from '@axhub/annotation';\n${hostImport}`, + ); + } + + content = content + .replace(//gu, '') + .replace(/React\.createElement\(AnnotationViewer,/gu, 'React.createElement(PrototypeAnnotationHost,'); + + fs.writeFileSync(filePath, content, 'utf8'); + console.log(`[migrate] ${path.relative(projectRoot, filePath)}`); + return true; +} + +let changed = 0; +for (const filePath of listIndexFiles()) { + if (migrateFile(filePath)) changed += 1; +} +console.log(`完成:迁移 ${changed} 个 index.tsx`); diff --git a/scripts/pack-oneos-v2-design-handoff.mjs b/scripts/pack-oneos-v2-design-handoff.mjs new file mode 100644 index 0000000..100ae4b --- /dev/null +++ b/scripts/pack-oneos-v2-design-handoff.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node +/** + * 打包 OneOS V2 设计规范「其他前端外发包」为 zip。 + * + * 用法(在仓库根目录): + * node scripts/pack-oneos-v2-design-handoff.mjs + * node scripts/pack-oneos-v2-design-handoff.mjs --out ~/Desktop + * + * 产出目录默认:dist/oneos-v2-design-handoff/ + * zip 文件名:oneos-v2-design-handoff-v{版本}-{日期}.zip + * + * 不含 UIComponents.tsx / prototypes(另一套前端不依赖本仓库 React 组件)。 + */ + +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +const DS = path.join(ROOT, 'src/resources/design-system'); + +function parseArgs(argv) { + const out = { outDir: path.join(ROOT, 'dist') }; + for (let i = 2; i < argv.length; i += 1) { + if (argv[i] === '--out' && argv[i + 1]) { + out.outDir = path.resolve(argv[++i]); + } else if (argv[i] === '--help' || argv[i] === '-h') { + out.help = true; + } + } + return out; +} + +function readDesignVersion() { + const designPath = path.join(DS, 'DESIGN.md'); + const text = fs.readFileSync(designPath, 'utf8'); + const m = text.match(/\|\s*文档版本\s*\|\s*\*\*([^*]+)\*\*/); + if (m) { + const raw = m[1].trim(); + const ver = raw.match(/v?\d+(?:\.\d+)*/i); + return ver ? ver[0].replace(/^v/i, '') : 'unknown'; + } + return 'unknown'; +} + +function dateStamp() { + const d = new Date(); + const y = d.getFullYear(); + const mo = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}${mo}${day}`; +} + +/** @type {Array<{ from: string, to: string, optional?: boolean }>} */ +const FILES = [ + { from: 'DESIGN.md', to: 'DESIGN.md' }, + { from: 'README.md', to: 'README.md' }, + { from: 'PRIORITY.md', to: 'PRIORITY.md' }, + { from: 'HANDOFF-其他前端.md', to: 'HANDOFF-其他前端.md' }, + { from: 'HANDOFF-Confluence摘要.md', to: 'HANDOFF-Confluence摘要.md' }, + { from: 'HANDOFF-Codex开发.md', to: 'HANDOFF-Codex开发.md' }, + { from: 'ai-prompt-template.md', to: 'ai-prompt-template.md' }, + { from: 'tokens.json', to: 'tokens.json' }, + { from: 'theme-variants.json', to: 'theme-variants.json', optional: true }, + { from: 'oneos-ds-tokens.css', to: 'oneos-ds-tokens.css' }, + { from: 'oneos-ds-filter-affordance.css', to: 'oneos-ds-filter-affordance.css' }, + { from: 'ruoyi-theme-preset.json', to: 'ruoyi-theme-preset.json' }, + { from: 'ruoyi-oneos-v2-theme.css', to: 'ruoyi-oneos-v2-theme.css' }, +]; + +function copyFile(src, dest, optional = false) { + if (!fs.existsSync(src)) { + if (optional) { + console.warn(`[skip optional] ${src}`); + return false; + } + throw new Error(`缺少文件: ${src}`); + } + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(src, dest); + return true; +} + +function copyDirRecursive(srcDir, destDir) { + if (!fs.existsSync(srcDir)) { + throw new Error(`缺少目录: ${srcDir}`); + } + fs.mkdirSync(destDir, { recursive: true }); + for (const name of fs.readdirSync(srcDir)) { + const from = path.join(srcDir, name); + const to = path.join(destDir, name); + const st = fs.statSync(from); + if (st.isDirectory()) { + copyDirRecursive(from, to); + } else if (st.isFile() && name.endsWith('.md')) { + fs.copyFileSync(from, to); + } + } +} + +function writePackageReadme(packRoot, version) { + const body = `# OneOS V2 设计规范 · 其他前端外发包 + +| 项 | 说明 | +|---|---| +| DESIGN 版本 | v${version} | +| 打包日期 | ${dateStamp()} | +| 展厅预览 | https://prototype.lnoneos.com/oneos-v2/index.html | +| 台账母版 | https://prototype.lnoneos.com/lease-contract-management/index.html | + +## 先读哪份 + +1. **一页摘要(可贴 Confluence)**:\`HANDOFF-Confluence摘要.md\` +2. **完整接入步骤(其他前端)**:\`HANDOFF-其他前端.md\` +3. **研发 Codex 接入**:\`HANDOFF-Codex开发.md\` +4. **总规范**:\`DESIGN.md\` +5. **给 AI**:\`ai-prompt-template.md\`(配合 HANDOFF §4 改路径) + +## 建议落盘 + +解压后复制到对方仓库: + +\`\`\`text +docs/oneos-v2/ +\`\`\` + +## 本包不含 + +- \`UIComponents.tsx\` 等 React 组件实现(请按规范自研等价控件) +- Axhub 业务原型源码 + +维护方:OneOS 产品 / 设计规范。 +`; + fs.writeFileSync(path.join(packRoot, '00-请先阅读.md'), body, 'utf8'); +} + +function zipFolder(folderPath, zipPath) { + const parent = path.dirname(folderPath); + const base = path.basename(folderPath); + // Prefer system zip for broad compatibility on macOS + const result = spawnSync('zip', ['-r', '-q', zipPath, base], { + cwd: parent, + encoding: 'utf8', + }); + if (result.status !== 0) { + throw new Error(`zip 失败: ${result.stderr || result.stdout || result.error}`); + } +} + +function main() { + const args = parseArgs(process.argv); + if (args.help) { + console.log(`Usage: node scripts/pack-oneos-v2-design-handoff.mjs [--out ]`); + process.exit(0); + } + + if (!fs.existsSync(DS)) { + console.error(`找不到设计规范目录: ${DS}`); + process.exit(1); + } + + const version = readDesignVersion(); + const stamp = dateStamp(); + const packName = `oneos-v2-design-handoff-v${version}-${stamp}`; + const stagingParent = path.join(args.outDir, 'oneos-v2-design-handoff'); + const packRoot = path.join(stagingParent, packName); + const zipPath = path.join(args.outDir, `${packName}.zip`); + + fs.rmSync(packRoot, { recursive: true, force: true }); + fs.mkdirSync(packRoot, { recursive: true }); + + for (const item of FILES) { + copyFile(path.join(DS, item.from), path.join(packRoot, item.to), item.optional); + } + copyDirRecursive(path.join(DS, 'chapters'), path.join(packRoot, 'chapters')); + writePackageReadme(packRoot, version); + + fs.mkdirSync(path.dirname(zipPath), { recursive: true }); + if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath); + zipFolder(packRoot, zipPath); + + const sizeKb = Math.round(fs.statSync(zipPath).size / 1024); + console.log('打包完成'); + console.log(` 版本: v${version}`); + console.log(` 目录: ${packRoot}`); + console.log(` ZIP : ${zipPath} (${sizeKb} KB)`); + console.log(''); + console.log('下一步:把 ZIP 发给对方,并附上展厅链接 https://prototype.lnoneos.com/oneos-v2/index.html'); +} + +main(); diff --git a/scripts/publish-all-to-s3.mjs b/scripts/publish-all-to-s3.mjs new file mode 100644 index 0000000..4edb268 --- /dev/null +++ b/scripts/publish-all-to-s3.mjs @@ -0,0 +1,349 @@ +#!/usr/bin/env node +/** + * 将项目原型页发布到 S3(阿里云 OSS)。 + * 流程:Make Admin export-html 构建静态包 → 按页面独立前缀上传到 OSS。 + * + * 用法: + * node scripts/publish-all-to-s3.mjs + * node scripts/publish-all-to-s3.mjs --dry-run + * node scripts/publish-all-to-s3.mjs --group prototypes + * node scripts/publish-all-to-s3.mjs --ids vehicle-management,oneos-prototype-nav + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +import extract from 'extract-zip'; +import { readServerInfo } from './utils/serverInfo.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, '..'); +const CONFIG_PATH = path.join(projectRoot, '.axhub/make/axhub.config.json'); + +function parseArgs(argv) { + const args = { + // 默认 oneos-v2;Make 多工程时务必传 --project-id,避免发到旧仓导致 NoSuchKey + projectId: 'oneos-v2', + adminOrigin: '', + dryRun: false, + group: 'all', + ids: [], + includeSource: true, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--project-id' && argv[i + 1]) args.projectId = argv[++i].trim(); + else if (arg === '--admin-origin' && argv[i + 1]) args.adminOrigin = argv[++i].trim(); + else if (arg === '--group' && argv[i + 1]) args.group = argv[++i].trim(); + else if (arg === '--ids' && argv[i + 1]) { + args.ids = argv[++i].split(',').map((id) => id.trim()).filter(Boolean); + } + else if (arg === '--dry-run') args.dryRun = true; + else if (arg === '--no-source') args.includeSource = false; + } + return args; +} + +function resolveAdminOrigin(explicit) { + if (explicit) return explicit.replace(/\/+$/u, ''); + const info = readServerInfo(projectRoot, 'admin'); + if (info?.origin) return info.origin.replace(/\/+$/u, ''); + return 'http://localhost:53817'; +} + +function readS3Config() { + const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); + const s3 = raw?.cloudPublishing?.s3; + if (!s3?.accessKeyId || !s3?.secretAccessKey || !s3?.bucket) { + throw new Error('请在 .axhub/make/axhub.config.json 中配置 cloudPublishing.s3'); + } + return { + ...s3, + pathAliases: s3.pathAliases && typeof s3.pathAliases === 'object' ? s3.pathAliases : {}, + includeSource: raw?.cloudPublishing?.publishSettings?.includeSource !== false, + }; +} + +function toPublishPath(resource) { + const raw = resource.filePath || resource.sourcePath || ''; + return String(raw).replace(/\\/gu, '/').replace(/\/index\.tsx?$/iu, ''); +} + +function resolveObjectPrefix(entry, pathAliases = {}) { + const alias = pathAliases[entry.id]; + if (alias) return String(alias).replace(/^\/+|\/+$/gu, ''); + + const normalized = entry.path.replace(/^\/+|\/+$/gu, ''); + const prototypeMatch = normalized.match(/^(?:src\/)?prototypes\/([^/]+)$/u); + // 与 Make 客户端「发布到对象存储」一致:/{prototype-id}/index.html(不加 prototypes/ 前缀) + if (prototypeMatch?.[1]) return prototypeMatch[1]; + const themeMatch = normalized.match(/^(?:src\/)?themes\/(.+)$/u); + if (themeMatch?.[1]) return `themes/${themeMatch[1]}`; + return entry.id; +} + +/** + * OSS 命名空间: + * - 优先显式 s3.prefix(不推荐:Make 客户端会把它当成唯一目录,丢掉原型 id) + * - 否则取 baseUrl 路径段(推荐:prefix 留空 + baseUrl=https://prototype.lnoneos.com/v2 → 命名空间 v2) + */ +function resolveS3Namespace(s3Config) { + const explicit = String(s3Config?.prefix || '').replace(/^\/+|\/+$/gu, ''); + if (explicit) return explicit; + try { + return new URL(String(s3Config?.baseUrl || '')).pathname.replace(/^\/+|\/+$/gu, ''); + } catch { + return ''; + } +} + +/** + * 公网链接规则: + * - OSS 实际上传前缀 = `{namespace}/{prototypeId}`(见 publishEntry) + * - baseUrl 若已带命名空间路径(.../v2),公网只拼 prototypeId,避免 /v2/v2/ + * - baseUrl 为域名根时,公网拼完整 objectPrefix + */ +function resolvePublicUrl(baseUrl, objectPrefix, entryPrefix = '', s3Prefix = '') { + const base = String(baseUrl || '').replace(/\/+$/u, ''); + const ossPrefix = String(s3Prefix || '').replace(/^\/+|\/+$/gu, ''); + const protoPrefix = String(entryPrefix || '').replace(/^\/+|\/+$/gu, ''); + const fullPrefix = String(objectPrefix || '').replace(/^\/+|\/+$/gu, ''); + + let pathname = ''; + try { + pathname = new URL(base).pathname.replace(/\/+$/u, ''); + } catch { + pathname = ''; + } + const baseAlreadyHasOssPrefix = Boolean( + ossPrefix && (pathname === `/${ossPrefix}` || pathname.endsWith(`/${ossPrefix}`)), + ); + const publicPrefix = baseAlreadyHasOssPrefix + ? (protoPrefix || fullPrefix.replace(new RegExp(`^${ossPrefix}/`), '')) + : fullPrefix; + const key = `${publicPrefix.replace(/^\/+|\/+$/gu, '')}/index.html`.replace(/^\/+/u, ''); + return `${base}/${key.split('/').map(encodeURIComponent).join('/')}`; +} + +function guessContentType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + const map = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.webp': 'image/webp', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.svg': 'image/svg+xml', + '.woff2': 'font/woff2', + '.woff': 'font/woff', + '.txt': 'text/plain; charset=utf-8', + }; + return map[ext] || 'application/octet-stream'; +} + +async function collectPublishEntries(adminOrigin, projectId, groupFilter, pathAliases = {}) { + const res = await fetch(`${adminOrigin}/api/projects/${encodeURIComponent(projectId)}/resources`); + if (!res.ok) { + throw new Error(`GET /api/projects/${projectId}/resources failed: ${res.status}`); + } + const data = await res.json(); + const groups = groupFilter === 'all' ? ['prototypes', 'themes'] : [groupFilter]; + const entries = []; + for (const group of groups) { + for (const item of data.resources?.[group] || []) { + const publishPath = toPublishPath(item); + if (!publishPath) continue; + entries.push({ + group, + id: item.id, + title: item.title || item.id, + path: publishPath, + prefix: resolveObjectPrefix({ path: publishPath, id: item.id }, pathAliases), + }); + } + } + return entries; +} + +async function exportHtmlZip(adminOrigin, projectId, entry, includeSource) { + const params = new URLSearchParams({ + path: entry.path, + projectId, + }); + if (includeSource) params.set('includeSource', 'true'); + const res = await fetch(`${adminOrigin}/api/export-html?${params.toString()}`); + if (!res.ok) { + const text = await res.text().catch(() => ''); + let message = text; + try { + message = JSON.parse(text).error || text; + } catch { + // keep raw text + } + throw new Error(message || `export-html failed (${res.status})`); + } + const buffer = Buffer.from(await res.arrayBuffer()); + if (buffer.length < 4 || buffer[0] !== 0x50 || buffer[1] !== 0x4b) { + throw new Error('export-html 未返回有效 ZIP'); + } + return buffer; +} + +function resolveS3Endpoint(s3Config) { + const bucket = String(s3Config.bucket || '').trim(); + const endpoint = String(s3Config.endpoint || '').trim().replace(/\/+$/u, ''); + const region = String(s3Config.region || 'cn-hangzhou').trim(); + + if (endpoint) { + // 配置里可能是 bucket 级域名(model-lnoneos.oss-...),AWS SDK 只需区域 endpoint。 + const bucketHostPrefix = bucket ? `${bucket}.` : ''; + if (bucketHostPrefix && endpoint.includes(bucketHostPrefix)) { + return endpoint.replace(bucketHostPrefix, ''); + } + return endpoint; + } + + return `https://oss-${region}.aliyuncs.com`; +} + +function createS3Client(s3Config) { + return new S3Client({ + region: s3Config.region || 'cn-hangzhou', + endpoint: resolveS3Endpoint(s3Config), + forcePathStyle: false, + credentials: { + accessKeyId: s3Config.accessKeyId, + secretAccessKey: s3Config.secretAccessKey, + }, + }); +} + +async function uploadDirectory(client, bucket, prefix, dir) { + const uploaded = []; + const walk = async (currentDir, relative = '') => { + for (const name of fs.readdirSync(currentDir)) { + const abs = path.join(currentDir, name); + const rel = relative ? `${relative}/${name}` : name; + if (fs.statSync(abs).isDirectory()) { + await walk(abs, rel); + continue; + } + const key = [prefix.replace(/^\/+|\/+$/gu, ''), rel].filter(Boolean).join('/'); + await client.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: fs.readFileSync(abs), + ContentType: guessContentType(abs), + })); + uploaded.push(key); + } + }; + await walk(dir); + return uploaded; +} + +async function publishEntry(client, s3Config, adminOrigin, projectId, entry, includeSource) { + const zipBuffer = await exportHtmlZip(adminOrigin, projectId, entry, includeSource); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'axhub-publish-')); + const zipPath = path.join(tempDir, 'bundle.zip'); + const extractDir = path.join(tempDir, 'site'); + fs.writeFileSync(zipPath, zipBuffer); + fs.mkdirSync(extractDir, { recursive: true }); + await extract(zipPath, { dir: extractDir }); + const namespace = resolveS3Namespace(s3Config); + const objectPrefix = namespace ? `${namespace}/${entry.prefix}` : entry.prefix; + const keys = await uploadDirectory(client, s3Config.bucket, objectPrefix, extractDir); + fs.rmSync(tempDir, { recursive: true, force: true }); + return { + url: resolvePublicUrl(s3Config.baseUrl, objectPrefix, entry.prefix, namespace), + fileCount: keys.length, + keys, + }; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const adminOrigin = resolveAdminOrigin(args.adminOrigin); + const s3Config = readS3Config(); + const includeSource = args.includeSource && s3Config.includeSource; + let entries = await collectPublishEntries(adminOrigin, args.projectId, args.group, s3Config.pathAliases); + if (args.ids.length > 0) { + const idSet = new Set(args.ids); + entries = entries.filter((entry) => idSet.has(entry.id)); + const missing = args.ids.filter((id) => !entries.some((entry) => entry.id === id)); + if (missing.length > 0) { + throw new Error(`未找到原型: ${missing.join(', ')}`); + } + } + + const namespace = resolveS3Namespace(s3Config); + console.log(`Admin: ${adminOrigin}`); + console.log(`Bucket: ${s3Config.bucket}`); + console.log(`Base URL: ${s3Config.baseUrl}`); + console.log(`OSS namespace: ${namespace || '(bucket root)'}`); + console.log(`Pages to publish: ${entries.length}`); + + if (args.dryRun) { + for (const entry of entries) { + const objectPrefix = namespace ? `${namespace}/${entry.prefix}` : entry.prefix; + const url = resolvePublicUrl(s3Config.baseUrl, objectPrefix, entry.prefix, namespace); + console.log(`[dry-run] ${entry.group}/${entry.id} -> ${objectPrefix}/index.html`); + console.log(` ${url}`); + } + return; + } + + const client = createS3Client(s3Config); + const results = []; + let failed = 0; + + for (let i = 0; i < entries.length; i += 1) { + const entry = entries[i]; + const label = `[${i + 1}/${entries.length}] ${entry.group}/${entry.id}`; + process.stdout.write(`${label} ... `); + try { + const result = await publishEntry(client, s3Config, adminOrigin, args.projectId, entry, includeSource); + console.log(`OK ${result.url} (${result.fileCount} files)`); + results.push({ ...entry, status: 'success', ...result }); + } catch (error) { + failed += 1; + const message = error instanceof Error ? error.message : String(error); + console.log(`FAIL ${message}`); + results.push({ ...entry, status: 'failed', error: message }); + } + } + + const reportPath = path.join( + projectRoot, + '.axhub/make/exports', + `cloud.publish.s3-all-${new Date().toISOString().replace(/[:.]/gu, '-')}.json`, + ); + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + fs.writeFileSync(reportPath, `${JSON.stringify({ + schemaVersion: 2, + projectId: args.projectId, + target: 's3', + method: 'export-html+s3-upload', + total: entries.length, + success: entries.length - failed, + failed, + completedAt: new Date().toISOString(), + results: results.map(({ keys, ...rest }) => ({ + ...rest, + fileCount: rest.fileCount ?? keys?.length ?? 0, + })), + }, null, 2)}\n`, 'utf8'); + + console.log(`\nDone: ${entries.length - failed}/${entries.length} succeeded`); + if (failed > 0) process.exitCode = 1; + console.log(`Report: ${reportPath}`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/scripts/remove-breadcrumbs-pass2.mjs b/scripts/remove-breadcrumbs-pass2.mjs new file mode 100644 index 0000000..9271657 --- /dev/null +++ b/scripts/remove-breadcrumbs-pass2.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node +import fs from 'fs'; +import path from 'path'; + +const ROOT = path.resolve('src/prototypes'); + +function walk(dir, out = []) { + for (const name of fs.readdirSync(dir)) { + const full = path.join(dir, name); + if (fs.statSync(full).isDirectory()) { + if (name === 'node_modules' || name === '.spec') continue; + walk(full, out); + } else if (/\.(jsx|tsx)$/.test(name)) out.push(full); + } + return out; +} + +function cleanup(source) { + let s = source; + + // Fix corrupted fragment from partial breadcrumb removal (37-新增后装设备) + s = s.replace( + /\{ style: styles\.page \},\s*\{ e\.preventDefault\(\); \} \}, '运维管理'\),[\s\S]*?React\.createElement\('span', \{ style: styles\.breadcrumbCurrent \}, '[^']+'\)\s*\),/g, + '{ style: styles.page },' + ); + + // Top bar: breadcrumb spans + req button -> req button only + s = s.replace( + /React\.createElement\('div', \{ style: \{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 \} \},\s*React\.createElement\('span'[\s\S]*?\),\s*(React\.createElement\(Button, \{ type: 'link'[\s\S]*?'查看需求说明'\)\s*)\),/g, + `React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 } }, $1),` + ); + + // Lease contract rows with broken partial breadcrumb + s = s.replace( + /React\.createElement\('div', \{ style: \{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 \} \},\s*React\.createElement\('span'[\s\S]*?\),\s*(React\.createElement\('(?:button|span)'[\s\S]*?'查看需求说明'\)[^)]*\))/g, + `React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 } }, $1)` + ); + + // Lease contract row - only breadcrumb spans ending with )), no req + s = s.replace( + /React\.createElement\('div', \{ style: \{ marginBottom: 16 \} \},\s*React\.createElement\('span'[\s\S]*?\)\),/g, + '' + ); + + // 后装设备 list page partial breadcrumb block inside createElement + s = s.replace( + /React\.createElement\('div', \{ style: styles\.breadcrumbLeft \},[\s\S]*?React\.createElement\('span', \{ style: styles\.breadcrumbCurrent \}, '[^']+'\)\s*\),/g, + '' + ); + + // Orphan breadcrumb link lines after broken removal + s = s.replace( + /\n\t\t\tReact\.createElement\('a', \{ href: '#', style: styles\.breadcrumbLink[\s\S]*?breadcrumbCurrent \}, '[^']+'\)\n\t\t\),/g, + '' + ); + + // Remove breadcrumbItems variable blocks + s = s.replace(/\n\tvar breadcrumbItems = \[[\s\S]*?\];\n/g, '\n'); + s = s.replace(/\n\tvar breadcrumbItems = \[[\s\S]*?\];\n/g, '\n'); + s = s.replace(/\n\t\tbreadcrumbItems\.push\([^)]+\);\n/g, ''); + + // 备车/交车任务 inline breadcrumb rows (partial) + s = s.replace( + /React\.createElement\('div', \{ style: styles\.breadcrumb[^}]*\},[\s\S]*?\),?\n?/g, + '' + ); + s = s.replace( + /React\.createElement\('span', \{ key: '[^']+', style: styles\.breadcrumbSep \}, ' \/ '\),?\n?\s*/g, + '' + ); + + // Arco/fault pages: remove Breadcrumb from Layout header if any remain + s = s.replace(/,\s*breadcrumb:\s*React\.createElement\(Breadcrumb[\s\S]*?\)/g, ''); + + // business pages partial breadcrumb in flex div + s = s.replace( + /React\.createElement\('div', \{ style: \{ display: 'flex', alignItems: 'center', marginBottom: 16 \} \},[\s\S]*?styles\.breadcrumbSep[\s\S]*?\),/g, + '' + ); + + // Fix double spaces in lease contract lines + s = s.replace(/marginBottom: 16 \} \},\s{2}React/g, 'marginBottom: 16 } }, React'); + + // 27-交车管理: broken topbar after breadcrumb removal + s = s.replace( + /React\.createElement\('div', \{ style: \{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 \} \},\s*React\.createElement\(Button,/g, + `React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 } }, React.createElement(Button,` + ); + + return s; +} + +function fixCollectPageIndent(source) { + return source.replace( + /(\}, '返回'\),\n)\s+React\.createElement\(Button,/g, + `$1\t\t\tReact.createElement(Button,` + ); +} + +const files = walk(ROOT); +let changed = 0; +for (const file of files) { + let source = fs.readFileSync(file, 'utf8'); + if (!/breadcrumb|Breadcrumb|面包屑/.test(source)) continue; + const next = fixCollectPageIndent(cleanup(source)); + if (next !== source) { + fs.writeFileSync(file, next); + changed++; + console.log('fixed:', path.relative(process.cwd(), file)); + } +} +console.log(`Pass 2 done. ${changed} file(s).`); diff --git a/scripts/remove-breadcrumbs-pass3.mjs b/scripts/remove-breadcrumbs-pass3.mjs new file mode 100644 index 0000000..9d93447 --- /dev/null +++ b/scripts/remove-breadcrumbs-pass3.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import fs from 'fs'; +import path from 'path'; + +const ROOT = path.resolve('src/prototypes'); + +function walk(dir, out = []) { + for (const name of fs.readdirSync(dir)) { + const full = path.join(dir, name); + if (fs.statSync(full).isDirectory()) { + if (name === 'node_modules' || name === '.spec') continue; + walk(full, out); + } else if (/\.(jsx|tsx)$/.test(name)) out.push(full); + } + return out; +} + +function cleanup(source) { + let s = source; + + // Lease contract topbar: remove span trail, keep req action + s = s.replace( + /React\.createElement\('div', \{ style: \{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 \} \}, React\.createElement\('span'[\s\S]*?\)\),\s*(React\.createElement\('(?:button|span)'[\s\S]*?'查看需求说明'\)[^)]*\)\),/g, + `React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 } }, $1),` + ); + + // Topbar only breadcrumb spans (no req button) + s = s.replace( + /React\.createElement\('div', \{ style: \{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 \} \}, React\.createElement\('span'[\s\S]*?\)\),/g, + '' + ); + + // Unused breadcrumb vars + s = s.replace(/\n\tvar breadcrumbItems = \[[\s\S]*?\];\n/g, '\n'); + s = s.replace(/\n\t\tvar breadcrumbItems = \[[\s\S]*?\];\n/g, '\n'); + s = s.replace(/\n\tvar breadcrumbNodes = \[[\s\S]*?\];\n/g, '\n'); + + // Broken 后装设备 page wrapper + s = s.replace( + /(\{ style: styles\.page \},\n)\s*React\.createElement\('a', \{ href: '#', style: styles\.requirementLink[\s\S]*?'查看需求说明'\)\n\t\t\),/g, + `$1\t\tReact.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', marginBottom: 16 } },\n\t\t\tReact.createElement('a', { href: '#', style: styles.requirementLink, onClick: function (e) { e.preventDefault(); setShowRequirementModal(true); } }, '查看需求说明')\n\t\t),\n` + ); + + // Empty if blocks left from breadcrumb removal in 业务台账 + s = s.replace(/\n\tif \(view === 'sales' \|\| view === 'project'\) \{\s*\}\n\tif \(view === 'project'\) \{\s*\}\n/g, '\n'); + + return s; +} + +const files = walk(ROOT); +let changed = 0; +for (const file of files) { + let source = fs.readFileSync(file, 'utf8'); + if (!/breadcrumb|Breadcrumb|面包屑/.test(source)) continue; + const next = cleanup(source); + if (next !== source) { + fs.writeFileSync(file, next); + changed++; + console.log('fixed:', path.relative(process.cwd(), file)); + } +} +console.log(`Pass 3 done. ${changed} file(s).`); diff --git a/scripts/remove-breadcrumbs.mjs b/scripts/remove-breadcrumbs.mjs new file mode 100644 index 0000000..05cfc60 --- /dev/null +++ b/scripts/remove-breadcrumbs.mjs @@ -0,0 +1,213 @@ +#!/usr/bin/env node +/** + * Remove breadcrumb UI from prototype pages under src/prototypes. + * Keeps sibling actions such as「查看需求说明」when present in breadcrumbRight. + */ +import fs from 'fs'; +import path from 'path'; + +const ROOT = path.resolve('src/prototypes'); + +function walk(dir, out = []) { + for (const name of fs.readdirSync(dir)) { + const full = path.join(dir, name); + const stat = fs.statSync(full); + if (stat.isDirectory()) { + if (name === 'node_modules' || name === '.spec') continue; + walk(full, out); + } else if (/\.(jsx|tsx)$/.test(name)) { + out.push(full); + } + } + return out; +} + +function removeBalancedFrom(source, openIndex) { + let i = openIndex; + if (source[i] !== '(') return null; + let depth = 0; + let inStr = null; + let escape = false; + for (; i < source.length; i++) { + const ch = source[i]; + if (inStr) { + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === inStr) inStr = null; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') { + inStr = ch; + continue; + } + if (ch === '(') depth++; + else if (ch === ')') { + depth--; + if (depth === 0) return source.slice(openIndex, i + 1); + } + } + return null; +} + +function removeReactBreadcrumbCalls(source) { + const token = 'React.createElement(Breadcrumb,'; + let result = source; + let idx = 0; + while ((idx = result.indexOf(token, idx)) !== -1) { + const open = result.indexOf('(', idx); + const chunk = removeBalancedFrom(result, open); + if (!chunk) break; + let start = idx; + let end = open + chunk.length; + while (start > 0 && /[\t ]/.test(result[start - 1])) start--; + if (result[start - 1] === ',') start--; + if (result[start - 1] === '\n' && result[start - 2] === ',') start--; + while (end < result.length && /[\t ,]/.test(result[end])) end++; + if (result[end] === '\n') end++; + result = result.slice(0, start) + result.slice(end); + idx = start; + } + return result; +} + +function removeBreadcrumbImports(source) { + return source + .replace(/^\s*var Breadcrumb = antd\.Breadcrumb;\n/gm, '') + .replace(/^\s*const Breadcrumb = antd\.Breadcrumb;\n/gm, '') + .replace(/^\s*import\s+\{[^}]*\bBreadcrumb\b[^}]*\}\s+from\s+['"][^'"]+['"];\n/gm, (line) => + line.replace(/\bBreadcrumb,?\s*/g, '').replace(/,\s*,/g, ',').replace(/\{\s*,/g, '{').replace(/,\s*\}/g, '}').replace(/import\s+\{\s*\}\s+from[^;]+;\n/g, '') + ); +} + +function removeNavBreadcrumbs(source) { + return source.replace( + /\n?\s*]*breadcrumb[^>]*>[\s\S]*?<\/nav>\n?/gi, + '\n' + ); +} + +function replaceJsxBreadcrumbBar(source) { + // JSX: breadcrumb row with optional right actions + return source.replace( + /\{\/\*[\s*]*面包屑[\s*]*\*\/\}\s*\n?\s*
[\s\S]*?
([\s\S]*?)<\/div>\s*<\/div>/g, + (_, right) => `\n\t\t\t
${right.trim()}
` + ); +} + +function replaceCreateElementBreadcrumbBar(source) { + // createElement breadcrumb with Left + Right — keep Right only + const pattern = /React\.createElement\(\s*'div',\s*\{ style: styles\.breadcrumb \},[\s\S]*?React\.createElement\(\s*'div',\s*\{ style: styles\.breadcrumbRight \},([\s\S]*?)\)\s*\)/g; + return source.replace( + pattern, + (_, right) => + `React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', marginBottom: 16 } }, ${right.trim()})` + ); +} + +function replaceSimpleCreateElementBreadcrumb(source) { + // div with only styles.breadcrumb path segments (no Right sibling) + return source.replace( + /React\.createElement\(\s*'div',\s*\{ style: styles\.breadcrumb[^}]*\},[\s\S]*?\),?\n?/g, + '' + ); +} + +function replaceLeaseContractBreadcrumbRow(source) { + // Long inline breadcrumb + req button row + return source.replace( + /React\.createElement\('div',\s*\{ style: \{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 \} \},\s*React\.createElement\('div',\s*\{ style: styles\.breadcrumb[^}]+\}[\s\S]*?'查看需求说明'\)\),/g, + (match) => { + const req = match.match(/React\.createElement\('button'[\s\S]*?'查看需求说明'\)/); + if (!req) return ''; + return `React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 } }, ${req[0]}),`; + } + ); +} + +function removeEmptyBreadcrumbWrappers(source) { + // div wrapper that only wrapped breadcrumb (now empty) + return source + .replace( + /React\.createElement\('div',\s*\{ style: \{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 \} \},\s*\),?\n?/g, + '' + ) + .replace( + /React\.createElement\('div',\s*\{ style: \{ marginBottom: 16 \} \},\s*\),?\n?/g, + '' + ) + .replace( + /React\.createElement\('div',\s*\{ className: 'vpr-collect-topbar__main' \},\s*\),?\n?/g, + '' + ); +} + +function transformFile(filePath) { + let source = fs.readFileSync(filePath, 'utf8'); + if (!/breadcrumb|Breadcrumb|面包屑/.test(source)) return false; + + const original = source; + source = removeReactBreadcrumbCalls(source); + source = removeBreadcrumbImports(source); + source = removeNavBreadcrumbs(source); + source = replaceJsxBreadcrumbBar(source); + source = replaceCreateElementBreadcrumbBar(source); + source = replaceSimpleCreateElementBreadcrumb(source); + source = replaceLeaseContractBreadcrumbRow(source); + source = removeEmptyBreadcrumbWrappers(source); + + // header with only req link after breadcrumb removed + source = source.replace( + /React\.createElement\(\s*'header',\s*\{ className: 'vr-page-header' \},\s*React\.createElement\(\s*Button,/g, + `React.createElement('header', { className: 'vr-page-header', style: { display: 'flex', justifyContent: 'flex-end', marginBottom: 16 } }, React.createElement(Button,` + ); + + // flex topbar: space-between -> flex-end when breadcrumb was first child + source = source.replace( + /justifyContent: 'space-between', marginBottom: 16 \} \},\s*React\.createElement\(Button, \{ type: 'link'/g, + `justifyContent: 'flex-end', marginBottom: 16 } }, React.createElement(Button, { type: 'link'` + ); + + if (source !== original) { + fs.writeFileSync(filePath, source); + return true; + } + return false; +} + +const files = walk(ROOT); +let changed = 0; +for (const file of files) { + if (transformFile(file)) { + changed++; + console.log('updated:', path.relative(process.cwd(), file)); + } +} + +// CSS tweak for collect topbar +const vprCss = path.join(ROOT, 'vehicle-pickup-receivable/styles/index.css'); +if (fs.existsSync(vprCss)) { + let css = fs.readFileSync(vprCss, 'utf8'); + const next = css + .replace('/* 办理页顶栏:返回 + 面包屑 */', '/* 办理页顶栏:返回 + 操作 */') + .replace( + /\.vm-page\.vpr-collect-page \.vpr-collect-topbar \{\n display: flex;\n align-items: center;\n gap: 16px;/, + `.vm-page.vpr-collect-page .vpr-collect-topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px;` + ) + .replace(/\n\.vm-page\.vpr-collect-page \.vpr-collect-topbar__main \{[^}]+\}\n?/g, '\n'); + if (next !== css) { + fs.writeFileSync(vprCss, next); + console.log('updated:', path.relative(process.cwd(), vprCss)); + } +} + +console.log(`\nDone. ${changed} file(s) updated.`); diff --git a/scripts/scan-entries.js b/scripts/scan-entries.js new file mode 100644 index 0000000..d13f5f3 --- /dev/null +++ b/scripts/scan-entries.js @@ -0,0 +1,18 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import { scanProjectEntries, writeEntriesManifestAtomic } from '../vite-plugins/utils/entriesManifestCore.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const projectRoot = path.resolve(__dirname, '..'); +const manifest = scanProjectEntries(projectRoot, ['prototypes', 'themes']); +const written = writeEntriesManifestAtomic(projectRoot, manifest); + +console.log( + 'Generated entries.json (schema v2) with', + Object.keys(written.js || {}).length, + 'js entries and', + Object.keys(written.html || {}).length, + 'html entries (using unified template)', +); diff --git a/scripts/shot-form-workbench-redesign.mjs b/scripts/shot-form-workbench-redesign.mjs new file mode 100644 index 0000000..de0038c --- /dev/null +++ b/scripts/shot-form-workbench-redesign.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node +/** + * 基于 Stripe Fintech 高端风格的 表单页 (故障处置) 与 工作台页 (不含版本日志) 浅/暗截图生成器 + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); +const formOutDir = path.join(root, 'src/resources/design-system/fault-form-shots'); +const workbenchOutDir = path.join(root, 'src/resources/design-system/workbench-shots'); + +async function loadPuppeteer() { + const require = createRequire(import.meta.url); + try { + return require('puppeteer'); + } catch { + return require('puppeteer-core'); + } +} + +async function main() { + fs.mkdirSync(formOutDir, { recursive: true }); + fs.mkdirSync(workbenchOutDir, { recursive: true }); + + const puppeteer = await loadPuppeteer(); + + const browser = await puppeteer.launch({ + headless: true, + executablePath: + process.env.PUPPETEER_EXECUTABLE_PATH || + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + defaultViewport: { width: 1440, height: 1024, deviceScaleFactor: 2 }, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + + const page = await browser.newPage(); + page.setDefaultTimeout(60000); + + const baseUrl = 'http://127.0.0.1:51720'; + const modes = ['light', 'dark']; + + // 1. 生成故障处置表单页截图 (Form) + for (const mode of modes) { + const demoUrl = `${baseUrl}/prototypes/oneos-prototype-demo/?oneosTheme=${mode}#proto=lease-contract-redesign`; + console.log(`正在渲染 表单页 (故障处置) (${mode})...`); + + await page.goto(demoUrl, { waitUntil: 'networkidle2' }); + await page.waitForSelector('.oneos-shell', { timeout: 30000 }); + await page.waitForSelector('.oneos-shell-frame', { timeout: 30000 }); + + await page.evaluate((m) => { + document.documentElement.dataset.dsMode = m; + document.documentElement.dataset.oneosTheme = m; + const shell = document.querySelector('.oneos-shell'); + if (shell) shell.dataset.oneosTheme = m; + }, mode); + + const iframeElement = await page.$('.oneos-shell-frame'); + if (iframeElement) { + const targetSrc = `${baseUrl}/prototypes/lease-contract-redesign/?concept=form&oneosTheme=${mode}`; + await page.evaluate((el, src) => { + el.src = src; + }, iframeElement, targetSrc); + } + + await new Promise((r) => setTimeout(r, 1200)); + + const childFrame = page.frames().find( + (f) => f !== page.mainFrame() && f.url().includes('lease-contract-redesign'), + ); + + if (childFrame) { + await childFrame.evaluate((m) => { + document.documentElement.dataset.dsMode = m; + document.documentElement.dataset.oneosTheme = m; + }, mode); + } + + await new Promise((r) => setTimeout(r, 400)); + + const fileName = `fault-form-${mode}.png`; + const outPath = path.join(formOutDir, fileName); + + await page.screenshot({ path: outPath, type: 'png', fullPage: false }); + console.log('Successfully written Form image:', fileName); + } + + // 2. 生成现代工作台页截图 (Workbench - 不含版本更新日志) + for (const mode of modes) { + const demoUrl = `${baseUrl}/prototypes/oneos-prototype-demo/?oneosTheme=${mode}#proto=lease-contract-redesign`; + console.log(`正在渲染 工作台页 (无版本日志) (${mode})...`); + + await page.goto(demoUrl, { waitUntil: 'networkidle2' }); + await page.waitForSelector('.oneos-shell', { timeout: 30000 }); + await page.waitForSelector('.oneos-shell-frame', { timeout: 30000 }); + + await page.evaluate((m) => { + document.documentElement.dataset.dsMode = m; + document.documentElement.dataset.oneosTheme = m; + const shell = document.querySelector('.oneos-shell'); + if (shell) shell.dataset.oneosTheme = m; + }, mode); + + const iframeElement = await page.$('.oneos-shell-frame'); + if (iframeElement) { + const targetSrc = `${baseUrl}/prototypes/lease-contract-redesign/?concept=workbench&oneosTheme=${mode}`; + await page.evaluate((el, src) => { + el.src = src; + }, iframeElement, targetSrc); + } + + await new Promise((r) => setTimeout(r, 1200)); + + const childFrame = page.frames().find( + (f) => f !== page.mainFrame() && f.url().includes('lease-contract-redesign'), + ); + + if (childFrame) { + await childFrame.evaluate((m) => { + document.documentElement.dataset.dsMode = m; + document.documentElement.dataset.oneosTheme = m; + }, mode); + } + + await new Promise((r) => setTimeout(r, 400)); + + const fileName = `workbench-${mode}.png`; + const outPath = path.join(workbenchOutDir, fileName); + + await page.screenshot({ path: outPath, type: 'png', fullPage: false }); + console.log('Successfully written Workbench image:', fileName); + } + + await browser.close(); + console.log('表单页与工作台页截图已成功全部生成!'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/shot-form-workbench-themes.mjs b/scripts/shot-form-workbench-themes.mjs new file mode 100644 index 0000000..18f2ce3 --- /dev/null +++ b/scripts/shot-form-workbench-themes.mjs @@ -0,0 +1,246 @@ +#!/usr/bin/env node +/** + * 租赁合同表单页 + 工作台 · 5 主题 × 浅/暗 截图 + * + * Usage: + * node scripts/shot-form-workbench-themes.mjs + * node scripts/shot-form-workbench-themes.mjs --base=http://127.0.0.1:51720 + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import { THEME_SHOTS, buildSkinCss } from './lease-contract-theme-skins.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); +const outRoot = path.join(root, 'src/resources/design-system/lease-contract-theme-shots'); + +const PAGES = [ + { + key: 'form', + title: '租赁合同 · 新增表单', + path: '/prototypes/lease-contract-management/', + ready: '.vm-page.lc-page', + shot: '.vm-page.lc-create-page, .vm-page.lc-page', + async prepare(page) { + // 已在表单页则跳过 + const onCreate = await page.$('.lc-create-page'); + if (onCreate) return; + // 点工具栏「新增」 + const clicked = await page.evaluate(() => { + const buttons = Array.from(document.querySelectorAll('button, a, .vm-btn')); + const btn = buttons.find((el) => (el.textContent || '').trim() === '新增'); + if (btn) { + btn.click(); + return true; + } + return false; + }); + if (!clicked) throw new Error('未找到「新增」按钮'); + await page.waitForSelector('.lc-create-page', { timeout: 30000 }); + await new Promise((r) => setTimeout(r, 500)); + }, + }, + { + key: 'workbench', + title: '工作台', + path: '/prototypes/oneos-web-workbench-new/', + ready: '.vm-page.wb-page', + shot: '.vm-page.wb-page', + /** 比稿截图不需要版本更新弹层遮挡首屏 */ + async beforeGoto(page) { + await page.evaluateOnNewDocument(() => { + const KEY = 'oneos-wb-release-seen-v1'; + const VERSION = '1.1.5'; + const operators = [ + '王冕', '周凯', '王磊', '刘洋', '黄倩', '孙敏', '陈静', '赵律师', '张明', + ]; + const map = {}; + try { + const raw = window.localStorage.getItem(KEY); + if (raw) Object.assign(map, JSON.parse(raw) || {}); + } catch { /* ignore */ } + for (const name of operators) map[name] = VERSION; + window.localStorage.setItem(KEY, JSON.stringify(map)); + }); + }, + async prepare(page) { + // 若仍弹出(缓存/旧会话),点「知道了」或强制隐藏 + await page.evaluate(async () => { + const confirm = document.querySelector('.wb-release-confirm'); + if (confirm instanceof HTMLElement) confirm.click(); + await new Promise((r) => setTimeout(r, 200)); + let style = document.getElementById('ds-theme-shot-hide-release'); + if (!style) { + style = document.createElement('style'); + style.id = 'ds-theme-shot-hide-release'; + document.head.appendChild(style); + } + style.textContent = ` + [data-annotation-id="wb-release-notes"], + .wb-overlay--release, + .wb-modal--release { + display: none !important; + visibility: hidden !important; + pointer-events: none !important; + } + `; + }); + await new Promise((r) => setTimeout(r, 150)); + }, + }, +]; + +function parseArgs(argv) { + const args = { base: 'http://127.0.0.1:51720' }; + for (const a of argv) { + if (a.startsWith('--base=')) args.base = a.slice('--base='.length); + } + return args; +} + +async function loadPuppeteer() { + const require = createRequire(import.meta.url); + try { + return require('puppeteer'); + } catch { + return require('puppeteer-core'); + } +} + +async function applySkin(page, theme, mode) { + const css = buildSkinCss(theme, mode); + await page.evaluate((cssText, modeName) => { + document.documentElement.dataset.dsMode = modeName; + let el = document.getElementById('ds-theme-shot-skin'); + if (!el) { + el = document.createElement('style'); + el.id = 'ds-theme-shot-skin'; + document.head.appendChild(el); + } + el.textContent = cssText; + }, css, mode); + await new Promise((r) => setTimeout(r, 250)); +} + +async function hideAnnotation(page) { + await page.evaluate(() => { + let el = document.getElementById('ds-theme-shot-hide-ann'); + if (!el) { + el = document.createElement('style'); + el.id = 'ds-theme-shot-hide-ann'; + document.head.appendChild(el); + } + el.textContent = ` + .axhub-annotation-host, [data-axhub-annotation], + [class*="Annotation"], [class*="annotation-toolbar"] { + display: none !important; visibility: hidden !important; + } + `; + }); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const base = args.base.replace(/\/$/, ''); + const puppeteer = await loadPuppeteer(); + const browser = await puppeteer.launch({ + headless: true, + executablePath: + process.env.PUPPETEER_EXECUTABLE_PATH + || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + defaultViewport: { width: 1440, height: 1100, deviceScaleFactor: 2 }, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + + const allResults = []; + + for (const pageCfg of PAGES) { + const outDir = path.join(outRoot, pageCfg.key); + fs.mkdirSync(outDir, { recursive: true }); + const page = await browser.newPage(); + page.setDefaultTimeout(60000); + const url = `${base}${pageCfg.path}`; + console.log(`\n== ${pageCfg.title} ==\n打开 ${url}`); + if (typeof pageCfg.beforeGoto === 'function') { + await pageCfg.beforeGoto(page); + } + await page.goto(url, { waitUntil: 'networkidle2' }); + await page.waitForSelector(pageCfg.ready, { timeout: 60000 }); + await pageCfg.prepare(page); + await hideAnnotation(page); + + for (const theme of THEME_SHOTS) { + for (const mode of ['light', 'dark']) { + await applySkin(page, theme, mode); + const file = `${theme.id}-${mode}.png`; + const outPath = path.join(outDir, file); + const handle = await page.$(pageCfg.shot); + if (handle) { + await handle.screenshot({ path: outPath, type: 'png' }); + } else { + await page.screenshot({ path: outPath, type: 'png' }); + } + console.log('wrote', pageCfg.key + '/' + file); + allResults.push({ + page: pageCfg.key, + pageTitle: pageCfg.title, + file: `${pageCfg.key}/${file}`, + theme: theme.name, + mode, + category: theme.category, + }); + } + } + await page.close(); + + const readme = `# ${pageCfg.title} · 主题比稿截图 + +生成时间:${new Date().toISOString().slice(0, 10)} + +| 文件 | 主题 | 模式 | +|------|------|------| +${allResults + .filter((r) => r.page === pageCfg.key) + .map((r) => `| \`${path.basename(r.file)}\` | ${r.theme} | ${r.mode === 'light' ? '浅色' : '暗色'} |`) + .join('\n')} +`; + fs.writeFileSync(path.join(outDir, 'README.md'), readme); + } + + // 更新总 README + const indexMd = `# 主题比稿截图总览 + +生成时间:${new Date().toISOString().slice(0, 10)} + +## 页面 + +| 目录 | 页面 | +|------|------| +| \`.\`(根目录 png) | 租赁合同 · 列表 | +| [\`form/\`](./form/) | 租赁合同 · 新增表单 | +| [\`workbench/\`](./workbench/) | 工作台 | + +## 五套主题 + +| 编号 | 主题 | 气质 | +|------|------|------| +| A | Linear | 冷 · 紫靛 · 精致中后台 | +| B | Stripe | 冷 · 品牌紫 · 金融精致 | +| C | Airbnb | 暖 · 珊瑚红 · 出行品牌 | +| D | Claude | 暖 · 陶土米 · 人文克制 | +| E | Intercom | 暖 · 米白黑 · 温和 B2B | + +每页均为 5 主题 × 浅/暗 = 10 张。皮肤为 token 叠加预览,定稿后再完整落地。 +`; + fs.writeFileSync(path.join(outRoot, 'README.md'), indexMd); + + await browser.close(); + console.log('\n完成 →', outRoot); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/shot-lease-contract-combo.mjs b/scripts/shot-lease-contract-combo.mjs new file mode 100644 index 0000000..5007b3f --- /dev/null +++ b/scripts/shot-lease-contract-combo.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node +/** + * 融合方案(Stripe Fintech Bento + 看板 + 左侧任务/右侧表单主从)三视图截图生成脚本 + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); +const outDir = path.join(root, 'src/resources/design-system/lease-contract-combo-shots'); + +async function loadPuppeteer() { + const require = createRequire(import.meta.url); + try { + return require('puppeteer'); + } catch { + return require('puppeteer-core'); + } +} + +const COMBOS = [ + { + view: 'list', + viewLabel: '列表模式 (List View)', + desc: 'Stripe Fintech 高端台账列表:显示项目、客户/签约主体、履约与审批状态、交付车辆与金额', + }, + { + view: 'kanban', + viewLabel: '看板模式 (Kanban View)', + desc: 'Pipeline 阶段履约看板:草稿箱、待我审批/盖章中、履约执行中、已终止/归档 4 大生命周期列', + }, + { + view: 'split', + viewLabel: '主从/表单模式 (Master-Detail View)', + desc: '左侧任务菜单 + 右侧表单内容区:包含合同概览、关联车辆与运单、电子盖章存证与合规审批', + }, +]; + +async function main() { + fs.mkdirSync(outDir, { recursive: true }); + const puppeteer = await loadPuppeteer(); + + const browser = await puppeteer.launch({ + headless: true, + executablePath: + process.env.PUPPETEER_EXECUTABLE_PATH || + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + defaultViewport: { width: 1440, height: 1024, deviceScaleFactor: 2 }, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + + const page = await browser.newPage(); + page.setDefaultTimeout(60000); + + const baseUrl = 'http://127.0.0.1:51720'; + const modes = ['light', 'dark']; + const results = []; + + for (const combo of COMBOS) { + for (const mode of modes) { + const demoUrl = `${baseUrl}/prototypes/oneos-prototype-demo/?oneosTheme=${mode}#proto=lease-contract-redesign`; + console.log(`正在渲染 Combo [${combo.view}] (${mode})...`); + + await page.goto(demoUrl, { waitUntil: 'networkidle2' }); + await page.waitForSelector('.oneos-shell', { timeout: 30000 }); + await page.waitForSelector('.oneos-shell-frame', { timeout: 30000 }); + + // 给外壳设置主题属性 + await page.evaluate((m) => { + document.documentElement.dataset.dsMode = m; + document.documentElement.dataset.oneosTheme = m; + const shell = document.querySelector('.oneos-shell'); + if (shell) shell.dataset.oneosTheme = m; + }, mode); + + // 找到子 iframe 并修改 src 为带 ?concept=combo&view=list/kanban/split 的页面 + const iframeElement = await page.$('.oneos-shell-frame'); + if (iframeElement) { + const targetSrc = `${baseUrl}/prototypes/lease-contract-redesign/?concept=combo&view=${combo.view}&oneosTheme=${mode}`; + await page.evaluate((el, src) => { + el.src = src; + }, iframeElement, targetSrc); + } + + // 等待新 iframe 载入并对齐主题 + await new Promise((r) => setTimeout(r, 1200)); + + const childFrame = page.frames().find( + (f) => f !== page.mainFrame() && f.url().includes('lease-contract-redesign'), + ); + + if (childFrame) { + await childFrame.evaluate((m) => { + document.documentElement.dataset.dsMode = m; + document.documentElement.dataset.oneosTheme = m; + }, mode); + } + + await new Promise((r) => setTimeout(r, 400)); + + const fileName = `combo-${combo.view}-${mode}.png`; + const outPath = path.join(outDir, fileName); + + await page.screenshot({ path: outPath, type: 'png', fullPage: false }); + console.log('Successfully written:', fileName); + results.push({ + fileName, + viewLabel: combo.viewLabel, + desc: combo.desc, + mode: mode === 'light' ? '浅色' : '暗色', + }); + } + } + + const readme = `# 租赁合同管理 — Stripe Bento 统一三视图 (列表 / 看板 / 左任务右表单) 截图 + +生成时间:${new Date().toISOString().slice(0, 10)} +底稿风格:以概念 2 (Stripe Fintech UI) 为基础视觉基调,三视图规范统一下的无缝切换体验。 + +| 文件 | 视图模式 | 模式 | 页面特性说明 | +|------|----------|------|--------------| +${results + .map( + (r) => + `| \`${r.fileName}\` | **${r.viewLabel}** | ${r.mode} | ${r.desc} |`, + ) + .join('\n')} +`; + + fs.writeFileSync(path.join(outDir, 'README.md'), readme); + await browser.close(); + console.log('三视图融合方案截图全部成功生成并保存至:', outDir); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/shot-lease-contract-redesign.mjs b/scripts/shot-lease-contract-redesign.mjs new file mode 100644 index 0000000..b3aaa91 --- /dev/null +++ b/scripts/shot-lease-contract-redesign.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node +/** + * 租赁合同全新 UI/UX 重构设计 (5 套完全不同布局交互) 截图生成器 + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); +const outDir = path.join(root, 'src/resources/design-system/lease-contract-redesign-shots'); + +async function loadPuppeteer() { + const require = createRequire(import.meta.url); + try { + return require('puppeteer'); + } catch { + return require('puppeteer-core'); + } +} + +const CONCEPTS = [ + { + id: 1, + name: 'Linear 极速命令行与聚焦工作区', + code: 'concept-1-linear', + desc: '快捷键驱动 (⌘K)、右侧抽屉滑动预览、极窄高密表格与微状态指示点', + }, + { + id: 2, + name: 'Stripe 金融台账与 Bento 分析枢纽', + code: 'concept-2-stripe', + desc: '高端 Fintech 视觉、Bento Bento Card 分析头图、Segmented Tabs 选项卡', + }, + { + id: 3, + name: 'Studio 双栏联动合同工作台', + code: 'concept-3-studio', + desc: '35% 左侧列表 | 65% 右侧完整画卷,零上下文切换,直接在线查阅盖章与运单', + }, + { + id: 4, + name: 'Stage Pipeline 阶段履约看板', + code: 'concept-4-kanban', + desc: '4 列生命周期 Pipeline 看板,清晰掌控草稿、审批、履约与归档卡点', + }, + { + id: 5, + name: 'Executive 决策视界与优雅卡片阵列', + code: 'concept-5-executive', + desc: 'Apple 高管级 Glassmorphism 渐变半透明大盘、3 列卡片矩阵与氛围高亮', + }, +]; + +async function main() { + fs.mkdirSync(outDir, { recursive: true }); + const puppeteer = await loadPuppeteer(); + + const browser = await puppeteer.launch({ + headless: true, + executablePath: + process.env.PUPPETEER_EXECUTABLE_PATH || + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + defaultViewport: { width: 1440, height: 1024, deviceScaleFactor: 2 }, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + + const page = await browser.newPage(); + page.setDefaultTimeout(60000); + + const baseUrl = 'http://127.0.0.1:51720'; + const modes = ['light', 'dark']; + const results = []; + + for (const concept of CONCEPTS) { + for (const mode of modes) { + const demoUrl = `${baseUrl}/prototypes/oneos-prototype-demo/?oneosTheme=${mode}#proto=lease-contract-redesign`; + console.log(`正在渲染 Concept ${concept.id} (${mode})...`); + + await page.goto(demoUrl, { waitUntil: 'networkidle2' }); + await page.waitForSelector('.oneos-shell', { timeout: 30000 }); + await page.waitForSelector('.oneos-shell-frame', { timeout: 30000 }); + + // 给外壳设置主题属性 + await page.evaluate((m) => { + document.documentElement.dataset.dsMode = m; + document.documentElement.dataset.oneosTheme = m; + const shell = document.querySelector('.oneos-shell'); + if (shell) shell.dataset.oneosTheme = m; + }, mode); + + // 找到子 iframe 并修改 src 为带有 ?concept=X 的页面 + let childFrame = null; + for (let i = 0; i < 30; i++) { + childFrame = page.frames().find( + (f) => f !== page.mainFrame() && f.url().includes('lease-contract-redesign'), + ); + if (childFrame) break; + await new Promise((r) => setTimeout(r, 200)); + } + + // 切换 iframe 内部 URL 为特定的 concept 与 mode + const iframeElement = await page.$('.oneos-shell-frame'); + if (iframeElement) { + const targetSrc = `${baseUrl}/prototypes/lease-contract-redesign/?concept=${concept.id}&oneosTheme=${mode}`; + await page.evaluate((el, src) => { + el.src = src; + }, iframeElement, targetSrc); + } + + // 等待新 iframe 载入并对齐主题 + await new Promise((r) => setTimeout(r, 1200)); + + childFrame = page.frames().find( + (f) => f !== page.mainFrame() && f.url().includes('lease-contract-redesign'), + ); + + if (childFrame) { + await childFrame.evaluate((m) => { + document.documentElement.dataset.dsMode = m; + document.documentElement.dataset.oneosTheme = m; + }, mode); + } + + await new Promise((r) => setTimeout(r, 400)); + + const fileName = `${concept.code}-${mode}.png`; + const outPath = path.join(outDir, fileName); + + await page.screenshot({ path: outPath, type: 'png', fullPage: false }); + console.log('Successfully written:', fileName); + results.push({ + fileName, + conceptId: concept.id, + conceptName: concept.name, + desc: concept.desc, + mode: mode === 'light' ? '浅色' : '暗色', + }); + } + } + + const readme = `# 租赁合同管理 — 5 套全新 UI/UX 重构方案截图 + +生成时间:${new Date().toISOString().slice(0, 10)} +统一标准:高大上(Premium / High-End SaaS)、全左侧菜单与顶栏外壳嵌入、100% 浅/暗双模式,支持真正差异化的 UI 布局与交互工作流。 + +| 文件 | 方案编号与名称 | 模式 | UI / UX 创新亮点 | +|------|----------------|------|------------------| +${results + .map( + (r) => + `| \`${r.fileName}\` | **Concept ${r.conceptId}**:${r.conceptName} | ${r.mode} | ${r.desc} |`, + ) + .join('\n')} +`; + + fs.writeFileSync(path.join(outDir, 'README.md'), readme); + await browser.close(); + console.log('全部 5 套全新的 UI/UX 设计稿已成功生成并保存至:', outDir); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/shot-lease-contract-themes.mjs b/scripts/shot-lease-contract-themes.mjs new file mode 100644 index 0000000..53166f8 --- /dev/null +++ b/scripts/shot-lease-contract-themes.mjs @@ -0,0 +1,206 @@ +#!/usr/bin/env node +/** + * 租赁合同列表(含 OneOS 侧栏 + 顶栏)· 5 主题 × 浅/暗 截图 + * + * Usage: + * node scripts/shot-lease-contract-themes.mjs + * node scripts/shot-lease-contract-themes.mjs --base=http://127.0.0.1:51720 + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import { THEME_SHOTS, buildSkinCss } from './lease-contract-theme-skins.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); +const outDir = path.join(root, 'src/resources/design-system/lease-contract-theme-shots'); + +function parseArgs(argv) { + const args = { base: 'http://127.0.0.1:51720' }; + for (const a of argv) { + if (a.startsWith('--base=')) args.base = a.slice('--base='.length); + } + return args; +} + +async function loadPuppeteer() { + const require = createRequire(import.meta.url); + try { + return require('puppeteer'); + } catch { + return require('puppeteer-core'); + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const demoUrl = `${args.base.replace(/\/$/, '')}/prototypes/oneos-prototype-demo/#proto=lease-contract-management`; + fs.mkdirSync(outDir, { recursive: true }); + + const puppeteer = await loadPuppeteer(); + + const browser = await puppeteer.launch({ + headless: true, + executablePath: + process.env.PUPPETEER_EXECUTABLE_PATH || + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + defaultViewport: { width: 1440, height: 1024, deviceScaleFactor: 2 }, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + + const page = await browser.newPage(); + page.setDefaultTimeout(60000); + + console.log('打开演示主页', demoUrl); + await page.goto(demoUrl, { waitUntil: 'networkidle2' }); + + // 1. 等待主 Shell 框架 + await page.waitForSelector('.oneos-shell', { timeout: 60000 }); + await page.waitForSelector('.oneos-shell-frame', { timeout: 60000 }); + + // 2. 准确定位真正的子 iframe(排除主页面 mainFrame) + let childFrame = null; + for (let i = 0; i < 30; i++) { + childFrame = page.frames().find( + (f) => f !== page.mainFrame() && f.url().includes('lease-contract-management'), + ); + if (childFrame) { + const ready = await childFrame.evaluate(() => !!document.querySelector('.vm-filter-card')); + if (ready) { + console.log(`子 iframe 页面已真正渲染就绪 (耗时 ${i * 200}ms)`); + break; + } + } + await new Promise((r) => setTimeout(r, 200)); + } + + if (!childFrame) { + throw new Error('未在演示壳中找到已渲染的子 iframe'); + } + + const modes = ['light', 'dark']; + const results = []; + + for (const theme of THEME_SHOTS) { + for (const mode of modes) { + const css = buildSkinCss(theme, mode); + + // A) 给主外壳 (Main Page) 应用主题 css 与浅/深属性 + await page.evaluate( + (cssText, modeName) => { + document.documentElement.dataset.dsMode = modeName; + document.documentElement.dataset.oneosTheme = modeName; + const shell = document.querySelector('.oneos-shell'); + if (shell) shell.dataset.oneosTheme = modeName; + + let el = document.getElementById('ds-theme-shot-skin'); + if (!el) { + el = document.createElement('style'); + el.id = 'ds-theme-shot-skin'; + document.head.appendChild(el); + } + el.textContent = cssText; + + // 彻底隐去 ThemeLab 弹框和标注图层 + let hideEl = document.getElementById('ds-theme-shot-hide'); + if (!hideEl) { + hideEl = document.createElement('style'); + hideEl.id = 'ds-theme-shot-hide'; + document.head.appendChild(hideEl); + } + hideEl.textContent = ` + .ds-theme-lab, .ds-theme-lab--collapsed, [data-annotation-id="lc-theme-lab"], + .axhub-annotation-host, [data-axhub-annotation], .PrototypeAnnotationHost, + iframe[src*="annotation"] { + display: none !important; visibility: hidden !important; pointer-events: none !important; + } + `; + }, + css, + mode, + ); + + // B) 给真正的子 iframe (Child Frame) 应用主题 css 与浅/深属性 + await childFrame.evaluate( + (cssText, modeName) => { + document.documentElement.dataset.dsMode = modeName; + document.documentElement.dataset.oneosTheme = modeName; + + let el = document.getElementById('ds-theme-shot-skin'); + if (!el) { + el = document.createElement('style'); + el.id = 'ds-theme-shot-skin'; + document.head.appendChild(el); + } + el.textContent = cssText; + + // 彻底隐去 ThemeLab 弹框和标注图层 + let hideEl = document.getElementById('ds-theme-shot-hide'); + if (!hideEl) { + hideEl = document.createElement('style'); + hideEl.id = 'ds-theme-shot-hide'; + document.head.appendChild(hideEl); + } + hideEl.textContent = ` + .ds-theme-lab, .ds-theme-lab--collapsed, [data-annotation-id="lc-theme-lab"], + .axhub-annotation-host, [data-axhub-annotation], .PrototypeAnnotationHost, + iframe[src*="annotation"] { + display: none !important; visibility: hidden !important; pointer-events: none !important; + } + `; + + // 展开“更多筛选”呈现完整条件 + const expand = document.querySelector( + 'button.ldb-filter-toggle, button.vm-btn-link.ldb-filter-toggle', + ); + if (expand && expand.textContent.includes('更多')) { + expand.click(); + } + }, + css, + mode, + ); + + await new Promise((r) => setTimeout(r, 400)); + + const file = `${theme.id}-${mode}.png`; + const outPath = path.join(outDir, file); + + // 截取完整演示外壳页面 + await page.screenshot({ path: outPath, type: 'png', fullPage: false }); + console.log('wrote', file); + results.push({ file, theme: theme.name, mode, category: theme.category }); + } + } + + const readme = `# 租赁合同(含 OneOS 侧栏 + 顶栏)· 5 主题比稿截图 + +生成时间:${new Date().toISOString().slice(0, 10)} +底稿:演示外壳 \`/prototypes/oneos-prototype-demo/#proto=lease-contract-management\`(包含完整左侧菜单与顶部栏,外壳与内容主题/浅暗 100% 深度绑定) + +| 文件 | 主题 | 分类 | 模式 | +|------|------|------|------| +${results.map((r) => `| \`${r.file}\` | ${r.theme} | ${r.category} | ${r.mode === 'light' ? '浅色' : '暗色'} |`).join('\n')} + +## 五套主题风格 + +| 编号 | 主题 | 调性与气质 | +|------|------|------| +| A | Linear | 冷 · 紫靛 · 精致中后台 | +| B | Stripe | 冷 · 品牌紫 · 金融精致 | +| C | Airbnb | 暖 · 珊瑚红 · 出行品牌 | +| D | Claude | 暖 · 陶土米 · 人文克制 | +| E | Intercom | 暖 · 米白黑 · 温和 B2B(浅色主按钮用品牌橙) | + +> 说明:外壳(侧边栏与顶栏)与内容区(租赁合同)在颜色调性与浅/暗模式下 100% 绑定一致,主题实验室悬浮弹框已移除。 +`; + fs.writeFileSync(path.join(outDir, 'README.md'), readme); + await browser.close(); + console.log('完成 →', outDir); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/smoke-preview-routes.mjs b/scripts/smoke-preview-routes.mjs new file mode 100644 index 0000000..6aa17b2 --- /dev/null +++ b/scripts/smoke-preview-routes.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node + +const baseUrl = process.argv[2] || 'http://localhost:51720'; +const targets = process.argv.slice(3).length > 0 + ? process.argv.slice(3) + : [ + '/prototypes/annotation-demo', + '/prototypes/beginner-guide', + '/themes/apple', + ]; + +let hasFailure = false; + +for (const target of targets) { + const requestUrl = new URL(target, baseUrl).toString(); + + try { + const response = await fetch(requestUrl, { + redirect: 'follow', + headers: { + Accept: 'text/html', + }, + }); + + const html = await response.text(); + const previewLoaderMatches = Array.from( + html.matchAll(/src="([^"]*__axhub-preview-loader\.js[^"]*)"/g), + (match) => match[1], + ); + const previewLoader = previewLoaderMatches[0] || null; + + let loaderScript = ''; + if (previewLoader) { + loaderScript = await fetch(new URL(previewLoader, baseUrl)).then((res) => res.text()); + } + + const ok = response.ok + && html.includes('
') + && previewLoaderMatches.length === 1 + && !html.includes('html-proxy') + && !html.includes('waitForBootstrap') + && loaderScript.includes('import PreviewComponent from') + && loaderScript.includes('import.meta.hot.accept(') + && html.includes('
'); + + if (!ok) { + hasFailure = true; + console.error(`[preview-smoke] FAIL ${requestUrl}`); + console.error(` status=${response.status}`); + console.error(` containsRoot=${html.includes('
')}`); + console.error(` previewLoaderCount=${previewLoaderMatches.length}`); + console.error(` removedHtmlProxy=${!html.includes('html-proxy')}`); + console.error(` removedLegacyLoader=${!html.includes('waitForBootstrap')}`); + console.error(` previewLoader=${Boolean(previewLoader)}`); + console.error(` loaderImportsEntry=${loaderScript.includes('import PreviewComponent from')}`); + console.error(` loaderHasAcceptBoundary=${loaderScript.includes('import.meta.hot.accept(')}`); + continue; + } + + console.log(`[preview-smoke] OK ${requestUrl}`); + } catch (error) { + hasFailure = true; + console.error(`[preview-smoke] ERROR ${requestUrl}`); + console.error(` ${(error && error.message) || error}`); + } +} + +if (hasFailure) { + process.exitCode = 1; +} diff --git a/scripts/snapshot-prototypes-legacy.mjs b/scripts/snapshot-prototypes-legacy.mjs new file mode 100644 index 0000000..2cf600b --- /dev/null +++ b/scripts/snapshot-prototypes-legacy.mjs @@ -0,0 +1,220 @@ +#!/usr/bin/env node +/** + * 将 src/prototypes 下业务原型整套复制为 -legacy,供设计规范 v2 对照。 + * 排除:oneos-prototype-nav、已有 *-legacy + * 复制时跳过:.spec/acp、.spec/prototype-comment-assets(体积大、非页面源码) + * + * Usage: node scripts/snapshot-prototypes-legacy.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); +const prototypesDir = path.join(root, 'src/prototypes'); +const sidebarPath = path.join(root, '.axhub/make/sidebar-tree.json'); + +const SKIP_DIRS = new Set(['oneos-prototype-nav']); +const RSYNC_EXCLUDES = [ + '--exclude=.spec/acp', + '--exclude=.spec/prototype-comment-assets', + '--exclude=node_modules', + '--exclude=.DS_Store', +]; + +function listSourceIds() { + return fs + .readdirSync(prototypesDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .filter((name) => !SKIP_DIRS.has(name) && !name.endsWith('-legacy')) + .sort(); +} + +function collectTitles(node, map = new Map()) { + if (!node) return map; + if (Array.isArray(node)) { + for (const child of node) collectTitles(child, map); + return map; + } + if (node.kind === 'item' && node.itemKey?.startsWith('prototypes/')) { + const id = node.itemKey.slice('prototypes/'.length); + map.set(id, node.title || id); + } + if (node.children) collectTitles(node.children, map); + if (node.prototypes) collectTitles(node.prototypes, map); + return map; +} + +function walkFiles(dir, out = []) { + if (!fs.existsSync(dir)) return out; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === '.git') continue; + walkFiles(full, out); + } else if (/\.(tsx?|jsx?|css|md|json|html|js)$/i.test(entry.name)) { + out.push(full); + } + } + return out; +} + +function rewriteFileContent(content, sourceIds) { + let next = content; + // Longer ids first to avoid partial replaces + const ids = [...sourceIds].sort((a, b) => b.length - a.length); + for (const id of ids) { + const legacy = `${id}-legacy`; + // path segments / imports + next = next.replaceAll(`prototypes/${id}/`, `prototypes/${legacy}/`); + next = next.replaceAll(`prototypes/${id}'`, `prototypes/${legacy}'`); + next = next.replaceAll(`prototypes/${id}"`, `prototypes/${legacy}"`); + next = next.replaceAll(`../${id}/`, `../${legacy}/`); + next = next.replaceAll(`'../${id}'`, `'../${legacy}'`); + next = next.replaceAll(`"../${id}"`, `"../${legacy}"`); + next = next.replaceAll(`/${id}/index.html`, `/${legacy}/index.html`); + } + return next; +} + +function patchAnnotationTitles(filePath, legacyId, displayTitle) { + if (!fs.existsSync(filePath)) return; + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const doc = JSON.parse(raw); + if (doc && typeof doc === 'object') { + if (typeof doc.title === 'string') doc.title = displayTitle; + if (typeof doc.name === 'string') doc.name = displayTitle; + if (doc.meta && typeof doc.meta === 'object') { + doc.meta.legacyOf = legacyId.replace(/-legacy$/, ''); + doc.meta.title = displayTitle; + } + fs.writeFileSync(filePath, `${JSON.stringify(doc, null, 2)}\n`); + } + } catch { + // leave as-is if not JSON object + } +} + +function copyOne(id) { + const src = path.join(prototypesDir, id); + const dest = path.join(prototypesDir, `${id}-legacy`); + if (fs.existsSync(dest)) { + fs.rmSync(dest, { recursive: true, force: true }); + } + const result = spawnSync( + 'rsync', + ['-a', ...RSYNC_EXCLUDES, `${src}/`, `${dest}/`], + { encoding: 'utf8' } + ); + if (result.status !== 0) { + // fallback cp + fs.cpSync(src, dest, { + recursive: true, + filter: (p) => + !p.includes(`${path.sep}.spec${path.sep}acp`) && + !p.includes(`${path.sep}prototype-comment-assets`) && + !p.includes(`${path.sep}node_modules`), + }); + } + return dest; +} + +function addLegacySidebarFolder(sidebar, legacyEntries) { + const folderId = 'folder-ds-v2-legacy-snapshots'; + const folder = { + id: folderId, + kind: 'folder', + title: '设计规范对照·旧版', + children: legacyEntries.map(({ id, title }) => ({ + id: `item:prototypes:${id}`, + kind: 'item', + title, + itemKey: `prototypes/${id}`, + })), + }; + + // nav-menu 只同步 title === 'OneOS' 的分区,旧版必须挂在其下 + const list = sidebar.prototypes || []; + const oneos = list.find((n) => n.kind === 'folder' && n.title === 'OneOS'); + if (!oneos) { + throw new Error('sidebar-tree.json 中未找到 OneOS 分区'); + } + oneos.children = Array.isArray(oneos.children) ? oneos.children : []; + // 移除顶层误挂的同名文件夹 + sidebar.prototypes = list.filter((n) => n.id !== folderId); + const idx = oneos.children.findIndex((n) => n.id === folderId); + if (idx >= 0) oneos.children[idx] = folder; + else oneos.children.push(folder); + sidebar.updatedAt = new Date().toISOString(); +} + +function main() { + const sourceIds = listSourceIds(); + const sidebar = JSON.parse(fs.readFileSync(sidebarPath, 'utf8')); + const titleMap = collectTitles(sidebar); + + console.log(`将复制 ${sourceIds.length} 个原型 → *-legacy`); + + for (const id of sourceIds) { + process.stdout.write(` copy ${id} ... `); + copyOne(id); + console.log('ok'); + } + + const legacyIds = sourceIds.map((id) => `${id}-legacy`); + const allSourceAndLegacy = new Set([...sourceIds, ...legacyIds]); + + // Rewrite cross-refs inside legacy trees to stay frozen against live restyles + for (const legacyId of legacyIds) { + const dir = path.join(prototypesDir, legacyId); + const baseId = legacyId.replace(/-legacy$/, ''); + const title = `【旧版】${titleMap.get(baseId) || baseId}`; + const files = walkFiles(dir); + for (const file of files) { + const before = fs.readFileSync(file, 'utf8'); + let after = rewriteFileContent(before, sourceIds); + if (after !== before) fs.writeFileSync(file, after); + } + patchAnnotationTitles(path.join(dir, 'annotation-source.json'), legacyId, title); + fs.writeFileSync( + path.join(dir, 'LEGACY.md'), + `# ${title} + +本目录为设计规范 v2 套用前的**冻结对照副本**(${new Date().toISOString().slice(0, 10)})。 + +- 源原型:\`src/prototypes/${baseId}/\` +- 请勿在此目录继续改业务;正式改造只在源目录进行 +- 已跳过复制:\`.spec/acp\`、\`.spec/prototype-comment-assets\`(批注大图/会话) +- 交叉引用已尽量改写为同套 \`*-legacy\`,避免引用被改版后的正式样式 +` + ); + } + + const legacyEntries = sourceIds.map((id) => ({ + id: `${id}-legacy`, + title: `【旧版】${titleMap.get(id) || id}`, + })); + addLegacySidebarFolder(sidebar, legacyEntries); + fs.writeFileSync(sidebarPath, `${JSON.stringify(sidebar, null, 2)}\n`); + console.log(`已更新 sidebar-tree.json(${legacyEntries.length} 个旧版入口)`); + + const sync = spawnSync( + 'npm', + ['run', 'nav:sync', '--', '--note', '注册设计规范 v2 前整套 legacy 对照副本'], + { cwd: root, encoding: 'utf8', shell: true } + ); + console.log(sync.stdout || ''); + if (sync.stderr) console.error(sync.stderr); + if (sync.status !== 0) { + console.error('nav:sync 失败,请手动执行 npm run nav:sync'); + process.exit(sync.status || 1); + } + + console.log('完成。'); +} + +main(); diff --git a/scripts/stitch-converter.mjs b/scripts/stitch-converter.mjs new file mode 100644 index 0000000..dc9b7b1 --- /dev/null +++ b/scripts/stitch-converter.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; + +function normalizeSlashes(input) { + return String(input || '').replace(/\\/g, '/'); +} + +function sanitizeName(rawName) { + return String(rawName || '') + .replace(/[^a-z0-9-]/gi, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .toLowerCase(); +} + +function parseArgs(argv) { + const args = [...argv]; + const projectDirArg = args.shift(); + const outputNameArg = args.shift(); + let projectRoot = process.cwd(); + let outputBaseDir = ''; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--project-root') { + projectRoot = path.resolve(args[index + 1] || projectRoot); + index += 1; + } else if (arg === '--output-base-dir') { + outputBaseDir = path.resolve(args[index + 1] || ''); + index += 1; + } + } + + if (!projectDirArg) throw new Error('Missing Stitch directory'); + const outputName = sanitizeName(outputNameArg || path.basename(projectDirArg)); + if (!outputName) throw new Error('Missing valid output name'); + return { + stitchDir: path.resolve(projectRoot, projectDirArg), + outputName, + projectRoot, + outputBaseDir: outputBaseDir || path.resolve(projectRoot, 'src/prototypes'), + }; +} + +function extractBody(html) { + const match = html.match(/]*>([\s\S]*?)<\/body>/iu); + return match?.[1]?.trim() || html; +} + +function hasPendingLogic(html) { + return /;', + '}', + '', + `const markup = \`${escapeTemplate(body)}\`;`, + '', + ].join('\n'), 'utf8'); + fs.writeFileSync(path.join(outputDir, 'style.css'), '.stitch-import { min-height: 100%; }\n', 'utf8'); + const requiresAi = hasPendingLogic(html); + const prompt = requiresAi + ? [ + 'Google Stitch 页面已导入完成,但检测到脚本或事件逻辑需要 AI 继续完善。', + '', + `请读取输出目录:\`${normalizeSlashes(path.relative(parsed.projectRoot, outputDir))}/\``, + '请先参考 `rules/development-guide.md` 和 `rules/design-guide.md`,再完善交互与动态逻辑。', + ].join('\n') + : null; + console.log(JSON.stringify({ + success: true, + outputDir, + requiresAi, + prompt, + reasons: requiresAi ? ['检测到脚本或内联事件逻辑'] : [], + })); +} + +try { + main(); +} catch (error) { + console.error(error?.message || String(error)); + process.exit(1); +} diff --git a/scripts/strip-prototype-nav-from-directory.mjs b/scripts/strip-prototype-nav-from-directory.mjs new file mode 100644 index 0000000..fe25ba0 --- /dev/null +++ b/scripts/strip-prototype-nav-from-directory.mjs @@ -0,0 +1,92 @@ +/** + * 从各原型 annotation-source.json 移除 ONE-OS 原型导航目录节点。 + * + * 用法: + * node scripts/strip-prototype-nav-from-directory.mjs + * node scripts/strip-prototype-nav-from-directory.mjs --prototype lease-contract-management + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const NAV_FOLDER_ID = 'oneos-project-nav'; + +function parseArgs(argv) { + const args = { prototype: '', projectRoot: '' }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--prototype' && argv[i + 1]) { + args.prototype = argv[++i].trim(); + } else if (arg === '--project-root' && argv[i + 1]) { + args.projectRoot = path.resolve(argv[++i].trim()); + } + } + return args; +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function stripNavNodes(nodes) { + const list = Array.isArray(nodes) ? nodes : []; + return list + .filter((node) => node?.id !== NAV_FOLDER_ID) + .map((node) => { + if (node?.type === 'folder' && Array.isArray(node.children)) { + return { ...node, children: stripNavNodes(node.children) }; + } + return node; + }); +} + +function listPrototypeIds(projectRoot) { + const prototypesRoot = path.join(projectRoot, 'src/prototypes'); + if (!fs.existsSync(prototypesRoot)) return []; + return fs.readdirSync(prototypesRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() + && fs.existsSync(path.join(prototypesRoot, entry.name, 'annotation-source.json'))) + .map((entry) => entry.name) + .sort((a, b) => a.localeCompare(b, 'zh-CN')); +} + +function stripPrototype(prototypeId, projectRoot) { + const annotationPath = path.join(projectRoot, 'src/prototypes', prototypeId, 'annotation-source.json'); + if (!fs.existsSync(annotationPath)) return false; + + const annotation = readJson(annotationPath); + const before = JSON.stringify(annotation.directory?.nodes || []); + annotation.directory = { + nodes: stripNavNodes(annotation.directory?.nodes), + }; + const after = JSON.stringify(annotation.directory.nodes || []); + if (before === after) { + console.log(`[skip] ${prototypeId}: 无导航节点`); + return false; + } + + annotation.data = annotation.data || {}; + annotation.data.updatedAt = Date.now(); + writeJson(annotationPath, annotation); + console.log(`[strip] ${prototypeId}: 已移除原型导航目录`); + return true; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const projectRoot = args.projectRoot || path.resolve(__dirname, '..'); + const targets = args.prototype ? [args.prototype] : listPrototypeIds(projectRoot); + + let changed = 0; + for (const prototypeId of targets) { + if (stripPrototype(prototypeId, projectRoot)) changed += 1; + } + console.log(`完成:处理 ${targets.length} 个原型,更新 ${changed} 个 annotation-source.json`); +} + +main(); diff --git a/scripts/subset-beginner-guide-fonts.mjs b/scripts/subset-beginner-guide-fonts.mjs new file mode 100644 index 0000000..8cc0c83 --- /dev/null +++ b/scripts/subset-beginner-guide-fonts.mjs @@ -0,0 +1,268 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { TextDecoder } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const DEFAULT_APP_ROOT = path.resolve(__dirname, '..'); + +const SOURCE_FILES = [ + 'index.tsx', + 'style.css', + 'index.html', + 'README.md', +]; + +const EXTRA_CODE_POINTS = [ + 0x00a0, + 0x2013, + 0x2014, + 0x2018, + 0x2019, + 0x201c, + 0x201d, + 0x2022, + 0x2026, + 0x3000, + 0x3001, + 0x3002, + 0x300a, + 0x300b, + 0x300c, + 0x300d, + 0xff01, + 0xff08, + 0xff09, + 0xff0c, + 0xff1a, + 0xff1b, + 0xff1f, +]; + +const ASCII_FALLBACK = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + + ' ~`!@#$%^&*()-_=+[]{}\\|;:\'",.<>/?'; + +function uniqueCharacters(input) { + const seen = new Set(); + const output = []; + for (const character of input) { + if (!seen.has(character)) { + seen.add(character); + output.push(character); + } + } + return output; +} + +export function parseSubsetArgs(argv = process.argv) { + const args = { + appRoot: DEFAULT_APP_ROOT, + dryRun: false, + sourceFontDir: process.env.AXHUB_BEGINNER_GUIDE_FONT_SOURCE_DIR || '', + }; + + const values = argv.slice(2); + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + if (value === '--dry-run') { + args.dryRun = true; + continue; + } + if (value === '--app-root') { + args.appRoot = path.resolve(values[index + 1] || ''); + index += 1; + continue; + } + if (value === '--source-font-dir') { + args.sourceFontDir = path.resolve(values[index + 1] || ''); + index += 1; + continue; + } + if (value === '--help' || value === '-h') { + args.help = true; + continue; + } + throw new Error(`Unknown option: ${value}`); + } + + return args; +} + +export function resolveBeginnerGuidePaths(options = {}) { + const appRoot = path.resolve(options.appRoot || DEFAULT_APP_ROOT); + const prototypeRoot = path.join(appRoot, 'src/prototypes/beginner-guide'); + const localSourceFontDir = path.join(appRoot, '.local/font-sources/beginner-guide'); + const sourceFontDir = options.sourceFontDir + ? path.resolve(options.sourceFontDir) + : fs.existsSync(localSourceFontDir) + ? localSourceFontDir + : prototypeRoot; + + return { + appRoot, + prototypeRoot, + sourceFontDir, + }; +} + +export function collectCharactersFromFiles(filePaths) { + const content = filePaths + .filter((filePath) => fs.existsSync(filePath)) + .map((filePath) => fs.readFileSync(filePath, 'utf8')) + .join('\n'); + + return uniqueCharacters(content); +} + +export function getGb2312LevelOneCharacters() { + const decoder = new TextDecoder('gb18030'); + const characters = []; + + for (let high = 0xb0; high <= 0xd7; high += 1) { + for (let low = 0xa1; low <= 0xfe; low += 1) { + characters.push(decoder.decode(Buffer.from([high, low]))); + } + } + + return uniqueCharacters(characters).join(''); +} + +export function collectSubsetCharacters(options = {}) { + const pageCharacters = Array.isArray(options.pageCharacters) + ? options.pageCharacters + : [...String(options.pageCharacters || '')]; + const commonCharacters = String( + options.commonCharacters ?? getGb2312LevelOneCharacters(), + ); + const extraCharacters = String( + options.extraCharacters + ?? `${ASCII_FALLBACK}${String.fromCodePoint(...EXTRA_CODE_POINTS)}`, + ); + + return uniqueCharacters(`${commonCharacters}${extraCharacters}${pageCharacters.join('')}`); +} + +export function getFontJobs(paths) { + return [ + { + weight: 400, + inputPath: path.join(paths.sourceFontDir, 'TsangerJinKai02-W04.ttf'), + outputPath: path.join(paths.prototypeRoot, 'TsangerJinKai02-W04.subset.woff2'), + }, + { + weight: 500, + inputPath: path.join(paths.sourceFontDir, 'TsangerJinKai02-W05.ttf'), + outputPath: path.join(paths.prototypeRoot, 'TsangerJinKai02-W05.subset.woff2'), + }, + ]; +} + +function getSourceFilePaths(paths) { + return SOURCE_FILES.map((fileName) => path.join(paths.prototypeRoot, fileName)); +} + +function formatSize(byteLength) { + if (byteLength < 1024) return `${byteLength} B`; + if (byteLength < 1024 * 1024) return `${(byteLength / 1024).toFixed(1)} KB`; + return `${(byteLength / 1024 / 1024).toFixed(1)} MB`; +} + +function printHelp() { + console.log(`Usage: node scripts/subset-beginner-guide-fonts.mjs [options] + +Options: + --dry-run Print the plan without writing fonts. + --app-root Client app root. Defaults to the current client. + --source-font-dir Directory containing TsangerJinKai02-W04.ttf and W05.ttf. + +Environment: + AXHUB_BEGINNER_GUIDE_FONT_SOURCE_DIR can provide the source font directory. +`); +} + +export async function subsetBeginnerGuideFonts(options = {}) { + const paths = resolveBeginnerGuidePaths(options); + const sourceFilePaths = getSourceFilePaths(paths); + const pageCharacters = collectCharactersFromFiles(sourceFilePaths); + const subsetCharacters = collectSubsetCharacters({ pageCharacters }); + const jobs = getFontJobs(paths); + + if (options.dryRun) { + return { + paths, + jobs, + characterCount: subsetCharacters.length, + written: [], + }; + } + + const missingInputs = jobs + .map((job) => job.inputPath) + .filter((inputPath) => !fs.existsSync(inputPath)); + if (missingInputs.length > 0) { + throw new Error( + [ + 'Missing source font file(s):', + ...missingInputs.map((inputPath) => ` - ${inputPath}`), + 'Pass --source-font-dir or set AXHUB_BEGINNER_GUIDE_FONT_SOURCE_DIR.', + ].join('\n'), + ); + } + + const { default: subsetFont } = await import('subset-font'); + fs.mkdirSync(paths.prototypeRoot, { recursive: true }); + + const written = []; + for (const job of jobs) { + const inputBuffer = fs.readFileSync(job.inputPath); + const outputBuffer = await subsetFont(inputBuffer, subsetCharacters.join(''), { + targetFormat: 'woff2', + preserveNameIds: [1, 2, 4, 6], + }); + fs.writeFileSync(job.outputPath, outputBuffer); + written.push({ + ...job, + inputSize: inputBuffer.byteLength, + outputSize: outputBuffer.byteLength, + }); + } + + return { + paths, + jobs, + characterCount: subsetCharacters.length, + written, + }; +} + +async function runCli() { + const args = parseSubsetArgs(process.argv); + if (args.help) { + printHelp(); + return; + } + + const result = await subsetBeginnerGuideFonts(args); + console.log(`Beginner guide subset characters: ${result.characterCount}`); + for (const job of result.jobs) { + if (args.dryRun) { + console.log(`dry-run ${job.weight}: ${job.inputPath} -> ${job.outputPath}`); + continue; + } + const written = result.written.find((item) => item.weight === job.weight); + console.log( + `${job.weight}: ${formatSize(written.inputSize)} -> ${formatSize(written.outputSize)} ${written.outputPath}`, + ); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { + runCli().catch((error) => { + console.error(error.message || error); + process.exit(1); + }); +} diff --git a/scripts/subset-beginner-guide-fonts.test.mjs b/scripts/subset-beginner-guide-fonts.test.mjs new file mode 100644 index 0000000..a4bdbf5 --- /dev/null +++ b/scripts/subset-beginner-guide-fonts.test.mjs @@ -0,0 +1,89 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + collectCharactersFromFiles, + collectSubsetCharacters, + getFontJobs, + parseSubsetArgs, + resolveBeginnerGuidePaths, +} from './subset-beginner-guide-fonts.mjs'; + +describe('beginner guide font subsetting', () => { + it('collects unique page characters while ignoring duplicate text', () => { + const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'beginner-font-subset-')); + const sourcePath = path.join(fixtureDir, 'index.tsx'); + fs.writeFileSync(sourcePath, '

发布到云端服务

发布到云端服务 ABC 123

'); + + const characters = collectCharactersFromFiles([sourcePath]); + + expect(characters).toContain('发'); + expect(characters).toContain('务'); + expect(characters).toContain('A'); + expect(characters.filter((character) => character === '发')).toHaveLength(1); + }); + + it('merges page text with a reusable common Chinese fallback set', () => { + const characters = collectSubsetCharacters({ + pageCharacters: ['专', '属', 'A'], + commonCharacters: '的一是专', + extraCharacters: ' A', + }); + + expect(characters.join('')).toContain('专'); + expect(characters.join('')).toContain('的'); + expect(characters.join('')).toContain('A'); + expect(characters.filter((character) => character === '专')).toHaveLength(1); + }); + + it('builds one subsetting job for each TsangerJinKai02 source weight', () => { + const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'beginner-font-app-')); + const prototypeRoot = path.join(appRoot, 'src/prototypes/beginner-guide'); + fs.mkdirSync(prototypeRoot, { recursive: true }); + + const paths = resolveBeginnerGuidePaths({ appRoot }); + const jobs = getFontJobs(paths); + + expect(jobs).toEqual([ + { + weight: 400, + inputPath: path.join(prototypeRoot, 'TsangerJinKai02-W04.ttf'), + outputPath: path.join(prototypeRoot, 'TsangerJinKai02-W04.subset.woff2'), + }, + { + weight: 500, + inputPath: path.join(prototypeRoot, 'TsangerJinKai02-W05.ttf'), + outputPath: path.join(prototypeRoot, 'TsangerJinKai02-W05.subset.woff2'), + }, + ]); + }); + + it('prefers ignored local source fonts over published prototype files', () => { + const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'beginner-font-local-')); + const localSourceRoot = path.join(appRoot, '.local/font-sources/beginner-guide'); + fs.mkdirSync(localSourceRoot, { recursive: true }); + + const paths = resolveBeginnerGuidePaths({ appRoot }); + + expect(paths.sourceFontDir).toBe(localSourceRoot); + }); + + it('parses script options for dry runs and explicit source font directories', () => { + const args = parseSubsetArgs([ + 'node', + 'subset-beginner-guide-fonts.mjs', + '--dry-run', + '--source-font-dir', + '/tmp/fonts', + '--app-root', + '/tmp/app', + ]); + + expect(args.dryRun).toBe(true); + expect(args.sourceFontDir).toBe('/tmp/fonts'); + expect(args.appRoot).toBe('/tmp/app'); + }); +}); diff --git a/scripts/sync-oneos-web-prototypes.mjs b/scripts/sync-oneos-web-prototypes.mjs new file mode 100644 index 0000000..3bc5ee7 --- /dev/null +++ b/scripts/sync-oneos-web-prototypes.mjs @@ -0,0 +1,355 @@ +#!/usr/bin/env node +/** + * 从 src/resources/oneos-web-legacy 生成 ONE-OS Web 端原型与侧边栏菜单项。 + * + * 用法:node scripts/sync-oneos-web-prototypes.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, '..'); +const legacyRoot = path.join(projectRoot, 'src/resources/oneos-web-legacy'); +const prototypesRoot = path.join(projectRoot, 'src/prototypes'); +const sidebarPath = path.join(projectRoot, '.axhub/make/sidebar-tree.json'); + +const PAGE_ID_RE = /^[a-z0-9-]+$/u; + +const XLL_MODULES = [ + { slug: 'oneos-web-safety-training', title: '业务-司机安全培训', skipCodegen: true }, +]; + +/** 从 ONE-OS Web 合包拆出、由项目内手写 index 维护的独立原型 */ +const STANDALONE_MODULES = [ + { slug: 'business-dept-ledger', title: '业务部台账', skipCodegen: true }, + { slug: 'customer-payment-collection', title: '客户回款情况', skipCodegen: true }, + { slug: 'vehicle-maintenance-ledger', title: '车辆维修明细', skipCodegen: true }, + { slug: 'oneos-web-h2-station-site', title: '站点信息', skipCodegen: true }, + { slug: 'oneos-web-h2-station-stats', title: '加氢站数量统计', skipCodegen: true }, + { slug: 'vehicle-return-settlement', title: '还车应结款', skipCodegen: true }, + { slug: 'vehicle-pickup-receivable', title: '提车应收款', skipCodegen: true }, + { slug: 'oneos-web-approval', title: '审批中心', skipCodegen: true }, + { slug: 'oneos-web-approval-initiated', title: '我发起的', skipCodegen: true }, + { slug: 'oneos-web-approval-todo', title: '我的待办', skipCodegen: true }, + { slug: 'oneos-web-approval-done', title: '我的已办', skipCodegen: true }, + { slug: 'oneos-web-approval-cc', title: '我的抄送', skipCodegen: true }, +]; + +const MODULES = [ + { slug: 'oneos-web-workbench', title: '工作台', files: ['工作台.jsx'] }, + { slug: 'oneos-web-login', title: '登录', files: ['登录.jsx'] }, + { slug: 'oneos-web-vehicle-asset', title: '车辆管理', files: ['车辆管理.jsx', '车辆管理-查看.jsx'] }, + { slug: 'oneos-web-contract-template', title: '合同模板管理', files: ['合同模板管理.jsx'] }, + { slug: 'oneos-web-help-center', title: '帮助中心', dir: '帮助中心' }, + { + slug: 'oneos-web-finance', + title: '财务管理', + dir: '财务管理', + excludeDirs: ['文档'], + excludeFiles: [ + '还车应结款.jsx', + '还车应结款-查看.jsx', + '还车应结款-费用明细.jsx', + '提车应收款.jsx', + '提车应收款-查看.jsx', + '提车应收款-开票信息.jsx', + '提车应收款-审核.jsx', + '提车应收款-提车收款单.jsx', + '提车收款单-编辑.jsx', + ], + }, + { slug: 'oneos-web-procurement', title: '采购管理', dir: '采购管理' }, + { slug: 'oneos-web-lease-contract', title: '车辆租赁合同', dir: '车辆租赁合同' }, + { slug: 'oneos-web-h2-station', title: '加氢站管理', dir: '加氢站管理', excludeDirs: ['export-tools', 'AI-加氢站站点信息-complete'], excludeFiles: ['站点信息.jsx'] }, + { slug: 'oneos-web-data-analysis', title: '数据分析', dir: '数据分析', excludeFiles: ['业务部台账.jsx', '客户回款情况.jsx'] }, + { slug: 'oneos-web-ledger-data', title: '台账数据', dir: '台账数据', excludeDirs: ['docs'], excludeFiles: ['车辆维修明细.jsx'] }, + { slug: 'oneos-web-business', title: '业务管理', dir: '业务管理', excludeDirs: ['文档', 'export-tools', 'AI-保险采购-complete'] }, + { slug: 'oneos-web-ops', title: '运维管理', dir: '运维管理' }, + { slug: 'oneos-web-requirements', title: '需求说明', dir: '需求说明', docsOnly: true }, +]; + +function slugifyPageId(relPath) { + const stem = relPath.replace(/\.jsx$/u, '').replace(/[\\/]/g, '-'); + const ascii = stem.replace(/[^\x00-\x7F]/gu, (ch) => `u${ch.charCodeAt(0).toString(16)}`); + let id = ascii.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + if (!id || !PAGE_ID_RE.test(id)) { + id = `p${Math.abs(hashCode(relPath)).toString(36)}`; + } + return id.slice(0, 48); +} + +function hashCode(value) { + let hash = 0; + for (let i = 0; i < value.length; i += 1) { + hash = ((hash << 5) - hash) + value.charCodeAt(i); + hash |= 0; + } + return hash; +} + +function collectJsxFiles(module) { + if (module.docsOnly) { + return []; + } + if (module.files) { + return module.files.map((file) => ({ + rel: file, + abs: path.join(legacyRoot, file), + title: file.replace(/\.jsx$/u, ''), + })); + } + const baseDir = path.join(legacyRoot, module.dir); + const results = []; + const walk = (dir, prefix = '') => { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith('.')) continue; + const full = path.join(dir, entry.name); + const rel = path.posix.join(module.dir, prefix, entry.name).replace(/\\/g, '/'); + if (entry.isDirectory()) { + if (module.excludeDirs?.includes(entry.name)) continue; + walk(full, prefix ? `${prefix}/${entry.name}` : entry.name); + continue; + } + if (!entry.name.endsWith('.jsx')) continue; + if (module.excludeFiles?.includes(entry.name)) continue; + results.push({ + rel, + abs: full, + title: entry.name.replace(/\.jsx$/u, ''), + }); + } + }; + walk(baseDir); + return results.sort((a, b) => a.rel.localeCompare(b.rel, 'zh-CN')); +} + +function prepareLegacyJsx(sourcePath, targetPath) { + const raw = fs.readFileSync(sourcePath, 'utf8'); + let content = raw; + if (!/export\s+default\s+Component/u.test(content)) { + content = `${content.trim()}\n\nexport default Component;\n`; + } + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, content, 'utf8'); +} + +function writeRequirementsPrototype(module) { + const protoDir = path.join(prototypesRoot, module.slug); + fs.mkdirSync(protoDir, { recursive: true }); + const indexTsx = `/** + * @name ${module.title} + * 自 ONE-OS web端/需求说明 归档 + */ +import React, { useMemo, useState } from 'react'; +import { Card, Col, Input, List, Row, Typography } from 'antd'; + +const DOC_MAP = import.meta.glob('../../resources/oneos-web-legacy/需求说明/**/*', { + query: '?raw', + import: 'default', + eager: true, +}); + +const FILES = Object.keys(DOC_MAP) + .map((key) => key.replace('../../resources/oneos-web-legacy/需求说明/', '')) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b, 'zh-CN')) + .map((rel) => ({ rel, title: rel })); + +export default function OneosWebRequirementsPage() { + const [query, setQuery] = useState(''); + const [active, setActive] = useState(FILES[0]?.rel ?? ''); + + const filtered = useMemo(() => { + const q = query.trim(); + if (!q) return FILES; + return FILES.filter((item) => item.title.includes(q)); + }, [query]); + + const content = useMemo(() => { + if (!active) return ''; + const key = \`../../resources/oneos-web-legacy/需求说明/\${active}\`; + return String(DOC_MAP[key] ?? '无法加载文档'); + }, [active]); + + return ( + + + setQuery(e.target.value)} style={{ width: 160 }} />}> + ( + setActive(item.rel)}> + {item.title} + + )} + /> + + + + + {content} + + + + ); +} +`; + fs.writeFileSync(path.join(protoDir, 'index.tsx'), indexTsx, 'utf8'); +} + +function writePrototype(module, pages) { + const protoDir = path.join(prototypesRoot, module.slug); + const pagesDir = path.join(protoDir, 'pages'); + fs.mkdirSync(pagesDir, { recursive: true }); + + const pageEntries = []; + const importLines = []; + const usedIds = new Set(); + + pages.forEach((page, index) => { + const fileName = `${String(index + 1).padStart(2, '0')}-${path.basename(page.rel)}`; + const target = path.join(pagesDir, fileName); + prepareLegacyJsx(page.abs, target); + const importName = `Page${index + 1}`; + importLines.push(`import ${importName} from './pages/${fileName}';`); + let id = slugifyPageId(page.rel); + while (usedIds.has(id)) id = `${id}-${index + 1}`; + usedIds.add(id); + pageEntries.push({ id, title: page.title, importName }); + }); + + const pagesArray = pageEntries.map((p) => ` { id: '${p.id}', title: '${p.title.replace(/'/g, "\\'")}', component: ${p.importName} },`).join('\n'); + + const indexTsx = `/** + * @name ${module.title} + * 自 ONE-OS web端 原稿复刻(${pages.length} 个页面) + */ +import '../../common/oneosWebLegacy/legacyGlobals'; +import React from 'react'; +${importLines.join('\n')} +import { OneosWebLegacyShell } from '../../common/oneosWebLegacy/OneosWebLegacyShell'; + +const pages = [ +${pagesArray} +]; + +export default function ${toComponentName(module.slug)}() { + return ( + + ); +} +`; + fs.writeFileSync(path.join(protoDir, 'index.tsx'), indexTsx, 'utf8'); +} + +function toComponentName(slug) { + return slug + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); +} + +const WORKBENCH_SIDEBAR_ITEM = { + id: 'item:prototypes:oneos-web-workbench', + kind: 'item', + title: '工作台', + itemKey: 'prototypes/oneos-web-workbench', +}; + +const ONEOS_NAV_FOLDER_ID = 'folder-1782874576229-r8atbu'; + +function toSidebarItem(module) { + return { + id: `item:prototypes:${module.slug}`, + kind: 'item', + title: module.title, + itemKey: `prototypes/${module.slug}`, + }; +} + +function ensureWorkbenchInOneOsRoot(prototypes) { + for (const item of prototypes) { + if (item?.id !== ONEOS_NAV_FOLDER_ID || item.kind !== 'folder' || !Array.isArray(item.children)) { + continue; + } + const rest = item.children.filter((child) => child.itemKey !== WORKBENCH_SIDEBAR_ITEM.itemKey); + item.children = [WORKBENCH_SIDEBAR_ITEM, ...rest]; + return; + } +} + +function updateSidebar(webModules, xllModules) { + const sidebar = JSON.parse(fs.readFileSync(sidebarPath, 'utf8')); + const prefix = 'item:prototypes:oneos-web-'; + const existing = (sidebar.prototypes ?? []).filter( + (item) => + item.id !== 'folder-prototypes-oneos-web' + && item.id !== 'folder-prototypes-xll-miniapp' + && item.itemKey !== 'prototypes/contract-template-management' + && !String(item.id).startsWith(prefix) + && !String(item.itemKey).startsWith('prototypes/oneos-web-'), + ); + + ensureWorkbenchInOneOsRoot(existing); + + const webModulesForFolder = webModules.filter((module) => module.slug !== 'oneos-web-workbench'); + + const xllFolder = { + id: 'folder-prototypes-xll-miniapp', + kind: 'folder', + title: '小羚羚小程序', + children: xllModules.map(toSidebarItem), + }; + + const oneosFolder = { + id: 'folder-prototypes-oneos-web', + kind: 'folder', + title: 'ONE-OS Web 端', + defaultExpanded: true, + children: webModulesForFolder.map(toSidebarItem), + }; + + sidebar.prototypes = [...existing, xllFolder, oneosFolder]; + sidebar.updatedAt = new Date().toISOString(); + fs.writeFileSync(sidebarPath, `${JSON.stringify(sidebar, null, 2)}\n`, 'utf8'); +} + +function main() { + if (!fs.existsSync(legacyRoot)) { + console.error('缺少 src/resources/oneos-web-legacy,请先复制 ONE-OS web端 资料。'); + process.exit(1); + } + + const allModules = [...XLL_MODULES, ...STANDALONE_MODULES, ...MODULES]; + + for (const module of allModules) { + if (module.skipCodegen) { + console.log(`[skip codegen] ${module.slug}(入口由项目内手写维护)`); + continue; + } + if (module.docsOnly) { + writeRequirementsPrototype(module); + console.log(`[ok] ${module.slug} (需求说明文档浏览)`); + continue; + } + const pages = collectJsxFiles(module); + if (!pages.length) { + console.warn(`[skip] ${module.slug}: 未找到 jsx 页面`); + continue; + } + writePrototype(module, pages); + console.log(`[ok] ${module.slug} (${pages.length} pages)`); + } + + updateSidebar(MODULES, XLL_MODULES); + console.log('已更新 .axhub/make/sidebar-tree.json'); +} + +main(); diff --git a/scripts/sync-project-metadata.d.ts b/scripts/sync-project-metadata.d.ts new file mode 100644 index 0000000..dae71bf --- /dev/null +++ b/scripts/sync-project-metadata.d.ts @@ -0,0 +1,39 @@ +export const PROJECT_ID: string; +export const PROJECT_NAME: string; +export const PRODUCT_NAME: string; +export const DEFAULT_CLIENT_ORIGIN: string; +export const DETERMINISTIC_UPDATED_AT: string; +export const resourceLayout: Record; +export const PROTOTYPE_PLACEHOLDER_GUIDE: { + kind: string; + title: string; + description: string; + steps: string[]; + tips: string[]; +}; + +export function normalizeMakeClientProjectIdentity(project: unknown): { + id: string; + name: string; +}; + +export function readMakeClientProjectIdentity(projectRoot: string): { + id: string; + name: string; +}; + +export function buildMakeProjectMetadata(projectRoot: string, options?: { + clientOrigin?: string; + includeAbsoluteFilePaths?: boolean; + includeRuntimeArtifacts?: boolean; +}): any; +export function resolveClientOrigin(projectRoot: string, fallbackOrigin?: string): string; +export function syncMakeProjectMetadata(projectRoot: string, options?: { + clientOrigin?: string; + includeRuntimeUrls?: boolean; + includeAbsoluteFilePaths?: boolean; + includeRuntimeArtifacts?: boolean; +}): { + metadata: any; + metadataPath: string; +}; diff --git a/scripts/sync-project-metadata.mjs b/scripts/sync-project-metadata.mjs new file mode 100644 index 0000000..2933301 --- /dev/null +++ b/scripts/sync-project-metadata.mjs @@ -0,0 +1,651 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +import { readServerInfo } from './utils/serverInfo.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const PROJECT_ID = 'make-project'; +export const PROJECT_NAME = ''; +export const PRODUCT_NAME = 'Axhub Make'; +export const MAKE_CLIENT_MARKER_KIND = 'axhub-make-client'; +export const MAKE_CLIENT_MARKER_RELATIVE_PATH = '.axhub/make/client.json'; +export const DEFAULT_CLIENT_ORIGIN = 'http://localhost:51720'; +export const DETERMINISTIC_UPDATED_AT = '2026-05-03T00:00:00.000Z'; + +export const resourceLayout = { + prototypes: ['src/prototypes'], + themes: ['src/themes'], + media: ['src/resources/assets'], +}; + +export const resourceWriteTargets = { + prototypes: { type: 'project-relative-path', path: resourceLayout.prototypes[0] }, + themes: { type: 'project-relative-path', path: resourceLayout.themes[0] }, + media: { type: 'project-relative-path', path: resourceLayout.media[0] }, +}; + +export const localExportCapabilities = { + html: false, + make: false, +}; + +export const PROTOTYPE_PLACEHOLDER_GUIDE = { + kind: 'prototype-empty', + title: '这个原型还没有开始创建', + description: '告诉 AI 你想做什么:目标用户、使用场景、页面内容和参考风格。', + steps: [ + '在本地 AI 软件中打开本页面', + '打开草稿创作原型', + ], + tips: [ + '模型不要用 auto,推荐:Claude Opus 4.8、Gemini 3.1 Pro、GPT-5.5、Kimi K2.7、GLM-5.2。', + '一个任务开一个新对话,避免多个需求互相干扰。', + '多用图片和语音描述,截图、草图和参考页面通常比长文字更清楚。', + '如果已有视觉规范,建议先创建设计系统。', + ], +}; + +// Snapshot from https://getdesign.md/api/cli/downloads?brands=... +const GETDESIGN_DOWNLOAD_SNAPSHOT_DATE = '2026-05-15'; +const GETDESIGN_THEME_STATS_BY_ID = { + 'airbnb': { sourceSlug: 'airbnb', downloads: 7111 }, + 'airtable': { sourceSlug: 'airtable', downloads: 3446 }, + 'apple': { sourceSlug: 'apple', downloads: 13995 }, + 'binance': { sourceSlug: 'binance', downloads: 2179 }, + 'bmw': { sourceSlug: 'bmw', downloads: 2917 }, + 'bmw-m': { sourceSlug: 'bmw-m', downloads: 555 }, + 'bugatti': { sourceSlug: 'bugatti', downloads: 1473 }, + 'cal-com': { sourceSlug: 'cal', downloads: 3691 }, + 'claude': { sourceSlug: 'claude', downloads: 10192 }, + 'clay': { sourceSlug: 'clay', downloads: 3383 }, + 'clickhouse': { sourceSlug: 'clickhouse', downloads: 2495 }, + 'cohere': { sourceSlug: 'cohere', downloads: 3083 }, + 'coinbase': { sourceSlug: 'coinbase', downloads: 3399 }, + 'composio': { sourceSlug: 'composio', downloads: 2346 }, + 'cursor': { sourceSlug: 'cursor', downloads: 4650 }, + 'elevenlabs': { sourceSlug: 'elevenlabs', downloads: 3438 }, + 'expo': { sourceSlug: 'expo', downloads: 2457 }, + 'ferrari': { sourceSlug: 'ferrari', downloads: 3158 }, + 'figma': { sourceSlug: 'figma', downloads: 4351 }, + 'framer': { sourceSlug: 'framer', downloads: 3781 }, + 'hashicorp': { sourceSlug: 'hashicorp', downloads: 2498 }, + 'ibm': { sourceSlug: 'ibm', downloads: 3093 }, + 'intercom': { sourceSlug: 'intercom', downloads: 3033 }, + 'june': { sourceSlug: 'june', downloads: 0 }, + 'kraken': { sourceSlug: 'kraken', downloads: 2738 }, + 'lamborghini': { sourceSlug: 'lamborghini', downloads: 2632 }, + 'linear': { sourceSlug: 'linear.app', downloads: 12010 }, + 'lovable': { sourceSlug: 'lovable', downloads: 2907 }, + 'mastercard': { sourceSlug: 'mastercard', downloads: 1275 }, + 'meta': { sourceSlug: 'meta', downloads: 1927 }, + 'minimax': { sourceSlug: 'minimax', downloads: 2742 }, + 'mintlify': { sourceSlug: 'mintlify', downloads: 3271 }, + 'miro': { sourceSlug: 'miro', downloads: 2587 }, + 'mistral-ai': { sourceSlug: 'mistral.ai', downloads: 2481 }, + 'mongodb': { sourceSlug: 'mongodb', downloads: 2399 }, + 'nike': { sourceSlug: 'nike', downloads: 2158 }, + 'notion': { sourceSlug: 'notion', downloads: 11442 }, + 'nvidia': { sourceSlug: 'nvidia', downloads: 2610 }, + 'ollama': { sourceSlug: 'ollama', downloads: 2602 }, + 'opencode': { sourceSlug: 'opencode.ai', downloads: 3033 }, + 'pinterest': { sourceSlug: 'pinterest', downloads: 2837 }, + 'playstation': { sourceSlug: 'playstation', downloads: 1507 }, + 'posthog': { sourceSlug: 'posthog', downloads: 2949 }, + 'renault': { sourceSlug: 'renault', downloads: 2186 }, + 'replicate': { sourceSlug: 'replicate', downloads: 2267 }, + 'revolut': { sourceSlug: 'revolut', downloads: 3289 }, + 'runway': { sourceSlug: 'runwayml', downloads: 2496 }, + 'sanity': { sourceSlug: 'sanity', downloads: 2322 }, + 'sentry': { sourceSlug: 'sentry', downloads: 2949 }, + 'shopify': { sourceSlug: 'shopify', downloads: 2063 }, + 'slack': { sourceSlug: 'slack', downloads: 0 }, + 'spacex': { sourceSlug: 'spacex', downloads: 3053 }, + 'spotify': { sourceSlug: 'spotify', downloads: 4283 }, + 'starbucks': { sourceSlug: 'starbucks', downloads: 1100 }, + 'stripe': { sourceSlug: 'stripe', downloads: 9401 }, + 'supabase': { sourceSlug: 'supabase', downloads: 4044 }, + 'superhuman': { sourceSlug: 'superhuman', downloads: 3186 }, + 'tesla': { sourceSlug: 'tesla', downloads: 3228 }, + 'the-verge': { sourceSlug: 'theverge', downloads: 1508 }, + 'together-ai': { sourceSlug: 'together.ai', downloads: 2352 }, + 'uber': { sourceSlug: 'uber', downloads: 2933 }, + 'vercel': { sourceSlug: 'vercel', downloads: 10946 }, + 'vodafone': { sourceSlug: 'vodafone', downloads: 750 }, + 'voltagent': { sourceSlug: 'voltagent', downloads: 2847 }, + 'warp': { sourceSlug: 'warp', downloads: 2541 }, + 'webflow': { sourceSlug: 'webflow', downloads: 2596 }, + 'wired': { sourceSlug: 'wired', downloads: 1488 }, + 'wise': { sourceSlug: 'wise', downloads: 3274 }, + 'xai': { sourceSlug: 'x.ai', downloads: 2599 }, + 'zapier': { sourceSlug: 'zapier', downloads: 2597 }, +}; + +function toPosix(input) { + return String(input || '').replace(/\\/g, '/'); +} + +function sortById(left, right) { + return left.id.localeCompare(right.id); +} + +function getThemeStats(themeId) { + return GETDESIGN_THEME_STATS_BY_ID[themeId] || null; +} + +function sortThemesByGetDesignDownloads(left, right) { + const leftStats = getThemeStats(left.id); + const rightStats = getThemeStats(right.id); + if (leftStats && rightStats) { + return rightStats.downloads - leftStats.downloads || sortById(left, right); + } + if (leftStats) return -1; + if (rightStats) return 1; + return sortById(left, right); +} + +function readJson(filePath) { + if (!fs.existsSync(filePath)) return null; + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + return null; + } +} + +function stringValue(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function isLegacyOfficialProjectName(projectId, name) { + return projectId === PROJECT_ID && (name === 'Axhub Make' || name === 'Axhub-Make'); +} + +export function normalizeMakeClientProjectIdentity(project) { + const rawProject = project && typeof project === 'object' && !Array.isArray(project) + ? project + : {}; + const id = stringValue(rawProject.id) || PROJECT_ID; + const name = typeof rawProject.name === 'string' ? rawProject.name.trim() : ''; + return { + id, + name: isLegacyOfficialProjectName(id, name) ? '' : name, + }; +} + +const PAGE_ID_RE = /^[a-z0-9-]+$/u; + +function normalizePageId(value) { + const id = stringValue(value); + return PAGE_ID_RE.test(id) ? id : ''; +} + +export function readMakeClientProjectIdentity(projectRoot) { + const marker = readJson(path.join(projectRoot, MAKE_CLIENT_MARKER_RELATIVE_PATH)); + const project = marker?.project && typeof marker.project === 'object' && !Array.isArray(marker.project) + ? marker.project + : {}; + const id = stringValue(project.id); + if (marker?.schemaVersion !== 1 || marker?.kind !== MAKE_CLIENT_MARKER_KIND || !id) { + return { + id: PROJECT_ID, + name: PROJECT_NAME, + }; + } + return normalizeMakeClientProjectIdentity(project); +} + +function writeJsonAtomic(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; + try { + fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); + fs.renameSync(tempPath, filePath); + } finally { + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + } +} + +function listFiles(rootDir, predicate) { + if (!fs.existsSync(rootDir)) return []; + const result = []; + for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) { + if (entry.name.startsWith('.')) continue; + const fullPath = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + result.push(...listFiles(fullPath, predicate)); + continue; + } + if (entry.isFile() && predicate(fullPath)) { + result.push(fullPath); + } + } + return result.sort((a, b) => toPosix(a).localeCompare(toPosix(b))); +} + +function isIgnoredResourceRelativePath(relativePath) { + const normalized = toPosix(relativePath).replace(/^\/+|\/+$/g, ''); + if (!normalized) return true; + if (normalized.toLowerCase() === 'readme.md') return true; + return normalized.split('/').some((segment) => segment.startsWith('.')); +} + +function readDisplayName(indexFilePath, fallback) { + if (!fs.existsSync(indexFilePath)) return fallback; + const source = fs.readFileSync(indexFilePath, 'utf8'); + const displayName = source.match(/@name\s+([^\n]+)/)?.[1]?.replace(/\*\/\s*$/u, '').trim(); + return displayName || fallback; +} + +function hasGeneratedPlaceholderSource(indexFilePath) { + if (!fs.existsSync(indexFilePath)) return false; + const source = fs.readFileSync(indexFilePath, 'utf8'); + const hasGeneratedShell = source.includes('placeholder-empty-page') + && source.includes('打开左侧默认引导页继续创建') + && source.includes('export default function Placeholder'); + return hasGeneratedShell && ( + source.includes('@axhub-placeholder prototype-empty') + || source.includes('className="placeholder-empty-page"') + ); +} + +function hasEmptyCanvasFile(prototypeDir) { + const canvasPath = path.join(prototypeDir, 'canvas.excalidraw'); + if (!fs.existsSync(canvasPath)) return true; + try { + const canvas = JSON.parse(fs.readFileSync(canvasPath, 'utf8')); + const elements = Array.isArray(canvas?.elements) ? canvas.elements : []; + const files = canvas?.files && typeof canvas.files === 'object' && !Array.isArray(canvas.files) + ? canvas.files + : {}; + return elements.length === 0 && Object.keys(files).length === 0; + } catch { + return false; + } +} + +function isGeneratedEmptyPrototypePlaceholder(prototypeDir, indexFilePath) { + return hasGeneratedPlaceholderSource(indexFilePath) && hasEmptyCanvasFile(prototypeDir); +} + +function getLiteralPropertyValue(objectLiteral, propertyName) { + const property = objectLiteral.properties.find((candidate) => ( + ts.isPropertyAssignment(candidate) + && ( + (ts.isIdentifier(candidate.name) && candidate.name.text === propertyName) + || (ts.isStringLiteral(candidate.name) && candidate.name.text === propertyName) + ) + )); + if (!property || !ts.isPropertyAssignment(property)) { + return null; + } + const initializer = property.initializer; + return ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer) + ? initializer.text.trim() + : null; +} + +function extractHashRouteFromCall(callExpression) { + const expression = callExpression.expression; + if (!ts.isIdentifier(expression) || expression.text !== 'defineHashPageRoute') { + return null; + } + + const pagesArg = callExpression.arguments[0]; + if (!pagesArg || !ts.isArrayLiteralExpression(pagesArg)) { + return null; + } + + const pages = []; + for (const element of pagesArg.elements) { + if (!ts.isObjectLiteralExpression(element)) { + continue; + } + const id = normalizePageId(getLiteralPropertyValue(element, 'id')); + const title = stringValue(getLiteralPropertyValue(element, 'title')); + if (id && title) { + pages.push({ id, title }); + } + } + if (!pages.length) { + return null; + } + + const optionsArg = callExpression.arguments[1]; + const requestedDefaultPageId = optionsArg && ts.isObjectLiteralExpression(optionsArg) + ? normalizePageId(getLiteralPropertyValue(optionsArg, 'defaultPageId')) + : ''; + const defaultPageId = pages.some((page) => page.id === requestedDefaultPageId) + ? requestedDefaultPageId + : pages[0].id; + + return { + pages, + defaultPageId, + }; +} + +function extractHashRouteFromFile(filePath) { + const sourceText = fs.readFileSync(filePath, 'utf8'); + const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS); + let route = null; + + const visit = (node) => { + if (route) { + return; + } + if (ts.isCallExpression(node)) { + route = extractHashRouteFromCall(node); + if (route) { + return; + } + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return route; +} + +function extractHashRouteMetadata(prototypeDir) { + const files = listFiles(prototypeDir, (filePath) => /\.(tsx?|mts|cts)$/iu.test(filePath)); + for (const filePath of files) { + try { + const route = extractHashRouteFromFile(filePath); + if (route) { + return route; + } + } catch { + // Ignore malformed source and leave the prototype without page metadata. + } + } + return null; +} + +function titleFromMarkdown(markdownPath, fallback) { + if (!fs.existsSync(markdownPath)) return fallback; + const source = fs.readFileSync(markdownPath, 'utf8'); + const heading = source.match(/^#\s+(.+)$/mu)?.[1]?.trim(); + return heading || fallback; +} + +function titleFromTokenFile(tokenPath, fallback) { + const token = readJson(tokenPath); + if (token && typeof token === 'object') { + const title = typeof token.title === 'string' + ? token.title.trim() + : typeof token.name === 'string' + ? token.name.trim() + : ''; + if (title) return title; + } + return fallback; +} + +function normalizeThemeResourceTitle(title, fallback) { + const rawTitle = typeof title === 'string' ? title.trim() : ''; + const fallbackTitle = typeof fallback === 'string' ? fallback.trim() : ''; + if (!rawTitle) return fallbackTitle; + + const repeatedThemeTitle = rawTitle.match(/^(.+?)\s+主题\s*-\s*(.+)$/u); + if (repeatedThemeTitle) { + const before = repeatedThemeTitle[1]?.trim(); + const after = repeatedThemeTitle[2]?.trim(); + if (before && after && before.toLowerCase() === after.toLowerCase()) { + return after; + } + } + + return rawTitle; +} + +function createAxureArtifactMetadata(projectRoot, prototypeName) { + const artifactRoot = path.join(projectRoot, '.axhub/make/artifacts/axure', prototypeName); + const files = { + manifestPath: '.axhub/make/artifacts/axure/{name}/manifest.json', + indexBundlePath: '.axhub/make/artifacts/axure/{name}/index-bundle.json', + axureJsonPath: '.axhub/make/artifacts/axure/{name}/axure-json.json', + coverSvgPath: '.axhub/make/artifacts/axure/{name}/cover.svg', + }; + const existing = {}; + for (const [key, template] of Object.entries(files)) { + const relativePath = template.replace('{name}', prototypeName); + if (fs.existsSync(path.join(projectRoot, relativePath))) { + existing[key] = relativePath; + } + } + if (!Object.keys(existing).length && !fs.existsSync(artifactRoot)) { + return undefined; + } + return { + axure: { + caseId: prototypeName, + ...existing, + }, + }; +} + +function createFigmaArtifactMetadata(projectRoot, prototypeName) { + const artifactRoot = path.join(projectRoot, '.axhub/make/artifacts/figma', prototypeName); + const files = { + canvasFigPath: '.axhub/make/artifacts/figma/{name}/canvas.fig', + metaPath: '.axhub/make/artifacts/figma/{name}/meta.json', + aiChatPath: '.axhub/make/artifacts/figma/{name}/ai_chat.json', + codeManifestPath: '.axhub/make/artifacts/figma/{name}/canvas.code-manifest.json', + manifestPath: '.axhub/make/artifacts/figma/{name}/manifest.json', + thumbnailPath: '.axhub/make/artifacts/figma/{name}/thumbnail.png', + }; + const existing = {}; + for (const [key, template] of Object.entries(files)) { + const relativePath = template.replace('{name}', prototypeName); + if (fs.existsSync(path.join(projectRoot, relativePath))) { + existing[key] = relativePath; + } + } + const imagesRelativePath = `.axhub/make/artifacts/figma/${prototypeName}/images`; + if (fs.existsSync(path.join(projectRoot, imagesRelativePath))) { + existing.imagesDir = imagesRelativePath; + } + if (!Object.keys(existing).length && !fs.existsSync(artifactRoot)) { + return undefined; + } + return { + figma: { + resourceId: prototypeName, + ...existing, + }, + }; +} + +function readRuntimeEntryKeys(projectRoot) { + const manifest = readJson(path.join(projectRoot, '.axhub/make/entries.json')); + const items = manifest && typeof manifest === 'object' && manifest.items && typeof manifest.items === 'object' + ? manifest.items + : {}; + const legacyJs = manifest && typeof manifest === 'object' && manifest.js && typeof manifest.js === 'object' + ? manifest.js + : {}; + const keys = new Set([ + ...Object.keys(items), + ...Object.keys(legacyJs), + ]); + + return Array.from(keys) + .map((key) => { + const normalizedKey = toPosix(key).replace(/^\/+/, '').replace(/\/+$/, ''); + const item = items[key]; + const group = stringValue(item?.group) || normalizedKey.split('/')[0] || ''; + const name = stringValue(item?.name) || normalizedKey.split('/').slice(1).join('/') || ''; + if (!normalizedKey || group !== 'prototypes' || !name) { + return null; + } + return { key: normalizedKey, name }; + }) + .filter(Boolean); +} + +function createRuntimeArtifactMetadata(projectRoot, prototypeName, runtimeEntryKeys, options = {}) { + if (options.includeRuntimeArtifacts === false) { + return undefined; + } + const entry = runtimeEntryKeys.find((item) => item.name === prototypeName || item.key === `prototypes/${prototypeName}`); + if (!entry) { + return undefined; + } + const builtJsPath = `dist/${entry.key}.js`; + if (!fs.existsSync(path.join(projectRoot, builtJsPath))) { + return undefined; + } + return { + runtime: { + builtJsPath, + }, + }; +} + +function createResourceClientUrl(clientOrigin, resourceKind, resourceName) { + const pathname = `/${resourceKind}/${encodeURIComponent(resourceName)}`; + const normalizedOrigin = String(clientOrigin || '').trim().replace(/\/+$/u, ''); + if (!normalizedOrigin) { + return pathname; + } + return `${normalizedOrigin}${pathname}`; +} + +function collectPrototypes(projectRoot, clientOrigin, options = {}) { + const roots = resourceLayout.prototypes.map((dir) => path.join(projectRoot, dir)); + const runtimeEntryKeys = readRuntimeEntryKeys(projectRoot); + const items = []; + for (const root of roots) { + if (!fs.existsSync(root)) continue; + for (const entry of fs.readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + if (!entry.isDirectory()) continue; + const indexFile = path.join(root, entry.name, 'index.tsx'); + if (!fs.existsSync(indexFile)) continue; + const filePath = toPosix(path.relative(projectRoot, indexFile)); + const route = extractHashRouteMetadata(path.join(root, entry.name)); + const placeholder = isGeneratedEmptyPrototypePlaceholder(path.join(root, entry.name), indexFile); + const item = { + id: entry.name, + name: entry.name, + title: readDisplayName(indexFile, entry.name), + clientUrl: createResourceClientUrl(clientOrigin, 'prototypes', entry.name), + previewMode: 'clientRuntime', + description: '', + updatedAt: DETERMINISTIC_UPDATED_AT, + filePath, + ...(options.includeAbsoluteFilePaths === false ? {} : { absoluteFilePath: path.resolve(indexFile) }), + ...(route ? { pages: route.pages, defaultPageId: route.defaultPageId } : {}), + ...(placeholder ? { placeholder: true, placeholderGuide: PROTOTYPE_PLACEHOLDER_GUIDE } : {}), + }; + const artifacts = { + ...createFigmaArtifactMetadata(projectRoot, entry.name), + ...createAxureArtifactMetadata(projectRoot, entry.name), + ...createRuntimeArtifactMetadata(projectRoot, entry.name, runtimeEntryKeys, options), + }; + if (Object.keys(artifacts).length > 0) { + item.artifacts = artifacts; + } + items.push(item); + } + } + return items.sort(sortById); +} + +function collectThemes(projectRoot, clientOrigin) { + const items = []; + for (const root of resourceLayout.themes.map((dir) => path.resolve(projectRoot, dir))) { + if (!fs.existsSync(root)) continue; + for (const entry of fs.readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + if (!entry.isDirectory()) continue; + const indexFile = path.join(root, entry.name, 'index.tsx'); + if (!fs.existsSync(indexFile)) continue; + const tokenPath = path.join(root, entry.name, 'designToken.json'); + const getdesignStats = getThemeStats(entry.name); + const rawTitle = readDisplayName(indexFile, titleFromTokenFile(tokenPath, entry.name)); + items.push({ + id: entry.name, + name: entry.name, + title: normalizeThemeResourceTitle(rawTitle, entry.name), + clientUrl: createResourceClientUrl(clientOrigin, 'themes', entry.name), + sourcePath: toPosix(path.relative(projectRoot, path.join(root, entry.name))), + updatedAt: DETERMINISTIC_UPDATED_AT, + ...(getdesignStats + ? { + getdesign: { + source: 'getdesign.md', + sourceSlug: getdesignStats.sourceSlug, + downloads: getdesignStats.downloads, + snapshotDate: GETDESIGN_DOWNLOAD_SNAPSHOT_DATE, + }, + } + : {}), + }); + } + } + return items + .sort(sortThemesByGetDesignDownloads) + .map((item) => Object.fromEntries(Object.entries(item).filter(([, value]) => value !== undefined))); +} + +export function buildMakeProjectMetadata(projectRoot, options = {}) { + const clientOrigin = String(options.clientOrigin ?? DEFAULT_CLIENT_ORIGIN).replace(/\/+$/u, ''); + const projectIdentity = readMakeClientProjectIdentity(projectRoot); + const prototypes = collectPrototypes(projectRoot, clientOrigin, options); + const themes = collectThemes(projectRoot, clientOrigin); + + return { + schemaVersion: 1, + project: { + id: projectIdentity.id, + name: projectIdentity.name, + }, + resources: { + prototypes, + themes, + }, + navigation: { + prototypes: prototypes.map((item) => item.id), + }, + orders: { + themes: themes.map((item) => item.id), + }, + capabilities: { + quickEdit: true, + quickEditMode: 'clientRuntime', + figmaExport: true, + axureExport: true, + localExports: localExportCapabilities, + }, + resourceWriteTargets, + }; +} + +export function resolveClientOrigin(projectRoot, fallbackOrigin = DEFAULT_CLIENT_ORIGIN) { + const runtime = readServerInfo(projectRoot, 'runtime'); + return runtime?.origin || fallbackOrigin; +} + +export function syncMakeProjectMetadata(projectRoot, options = {}) { + const metadata = buildMakeProjectMetadata(projectRoot, { + clientOrigin: options.includeRuntimeUrls === true + ? options.clientOrigin ?? resolveClientOrigin(projectRoot) + : '', + includeAbsoluteFilePaths: options.includeAbsoluteFilePaths === true, + includeRuntimeArtifacts: options.includeRuntimeArtifacts === true, + }); + const metadataPath = path.join(projectRoot, '.axhub/make/project.json'); + writeJsonAtomic(metadataPath, metadata); + return { metadata, metadataPath }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { + const appRoot = path.resolve(__dirname, '..'); + const { metadata, metadataPath } = syncMakeProjectMetadata(appRoot); + console.log(`Synced ${metadata.project.name || 'unnamed project'} metadata: ${metadataPath}`); +} diff --git a/scripts/sync-project-metadata.mjs.d.ts b/scripts/sync-project-metadata.mjs.d.ts new file mode 100644 index 0000000..dae71bf --- /dev/null +++ b/scripts/sync-project-metadata.mjs.d.ts @@ -0,0 +1,39 @@ +export const PROJECT_ID: string; +export const PROJECT_NAME: string; +export const PRODUCT_NAME: string; +export const DEFAULT_CLIENT_ORIGIN: string; +export const DETERMINISTIC_UPDATED_AT: string; +export const resourceLayout: Record; +export const PROTOTYPE_PLACEHOLDER_GUIDE: { + kind: string; + title: string; + description: string; + steps: string[]; + tips: string[]; +}; + +export function normalizeMakeClientProjectIdentity(project: unknown): { + id: string; + name: string; +}; + +export function readMakeClientProjectIdentity(projectRoot: string): { + id: string; + name: string; +}; + +export function buildMakeProjectMetadata(projectRoot: string, options?: { + clientOrigin?: string; + includeAbsoluteFilePaths?: boolean; + includeRuntimeArtifacts?: boolean; +}): any; +export function resolveClientOrigin(projectRoot: string, fallbackOrigin?: string): string; +export function syncMakeProjectMetadata(projectRoot: string, options?: { + clientOrigin?: string; + includeRuntimeUrls?: boolean; + includeAbsoluteFilePaths?: boolean; + includeRuntimeArtifacts?: boolean; +}): { + metadata: any; + metadataPath: string; +}; diff --git a/scripts/sync-project-prototype-directory.mjs b/scripts/sync-project-prototype-directory.mjs new file mode 100644 index 0000000..72bab96 --- /dev/null +++ b/scripts/sync-project-prototype-directory.mjs @@ -0,0 +1,160 @@ +/** + * 将 .axhub/make/sidebar-tree.json 同步到原型导航页 nav-menu.json。 + * 不再向各原型 annotation-source.json 注入「ONE-OS 原型导航」目录。 + * + * 用法: + * node scripts/sync-project-prototype-directory.mjs + * node scripts/sync-project-prototype-directory.mjs --prototype vehicle-management + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const NAV_FOLDER_ID = 'oneos-project-nav'; + +function parseArgs(argv) { + const args = { prototype: '', projectRoot: '' }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--prototype' && argv[i + 1]) { + args.prototype = argv[++i].trim(); + } else if (arg === '--project-root' && argv[i + 1]) { + args.projectRoot = path.resolve(argv[++i].trim()); + } + } + return args; +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function loadSidebarPrototypes(projectRoot) { + const sidebarPath = path.join(projectRoot, '.axhub/make/sidebar-tree.json'); + if (!fs.existsSync(sidebarPath)) { + throw new Error(`缺少侧边栏配置:${sidebarPath}`); + } + const sidebar = readJson(sidebarPath); + return Array.isArray(sidebar.prototypes) ? sidebar.prototypes : []; +} + +function listPrototypeIdsWithAnnotation(projectRoot) { + const prototypesRoot = path.join(projectRoot, 'src/prototypes'); + if (!fs.existsSync(prototypesRoot)) return []; + return fs.readdirSync(prototypesRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() + && fs.existsSync(path.join(prototypesRoot, entry.name, 'annotation-source.json'))) + .map((entry) => entry.name) + .sort((a, b) => a.localeCompare(b, 'zh-CN')); +} + +function stripNavNodes(nodes) { + const list = Array.isArray(nodes) ? nodes : []; + return list + .filter((node) => node?.id !== NAV_FOLDER_ID) + .map((node) => { + if (node?.type === 'folder' && Array.isArray(node.children)) { + return { ...node, children: stripNavNodes(node.children) }; + } + return node; + }); +} + +function fixPublishedLinks(nodes, prototypeId) { + const walk = (items) => { + for (const node of items || []) { + if (node?.type === 'link' && typeof node.href === 'string') { + const href = node.href.trim(); + const selfMatch = href.match(new RegExp(`^/prototypes/${prototypeId.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}(?:[#?].*)?$`, 'u')); + if (selfMatch) { + node.href = './index.html'; + node.target = node.target || 'self'; + } else { + const otherMatch = href.match(/^\/prototypes\/([^/?#]+)(?:[#?].*)?$/u); + if (otherMatch) { + node.href = `../${otherMatch[1]}/index.html`; + node.target = node.target || 'self'; + } + } + } + if (node?.type === 'folder' && Array.isArray(node.children)) { + walk(node.children); + } + } + }; + walk(nodes); +} + +function cleanupPrototypeDirectory(prototypeId, projectRoot) { + const annotationPath = path.join(projectRoot, 'src/prototypes', prototypeId, 'annotation-source.json'); + if (!fs.existsSync(annotationPath)) { + console.log(`[skip] ${prototypeId}: 无 annotation-source.json`); + return false; + } + + const annotation = readJson(annotationPath); + const existingNodes = Array.isArray(annotation.directory?.nodes) ? annotation.directory.nodes : []; + const cleanedNodes = stripNavNodes(existingNodes); + fixPublishedLinks(cleanedNodes, prototypeId); + + const before = JSON.stringify(existingNodes); + const after = JSON.stringify(cleanedNodes); + if (before === after) { + console.log(`[ok] ${prototypeId}: 目录已不含原型导航`); + return false; + } + + annotation.directory = { nodes: cleanedNodes }; + annotation.data = annotation.data || {}; + annotation.data.updatedAt = Date.now(); + writeJson(annotationPath, annotation); + console.log(`[cleanup] ${prototypeId}: 已移除原型导航目录节点`); + return true; +} + +function filterNavMenuPrototypes(sidebarItems) { + return sidebarItems.filter((node) => node?.kind === 'folder' && node?.title === 'OneOS'); +} + +function syncPrototypeNavMenu(projectRoot, sidebarItems) { + const navMenuPath = path.join(projectRoot, 'src/prototypes/oneos-prototype-nav/nav-menu.json'); + if (!fs.existsSync(path.dirname(navMenuPath))) return false; + const filtered = filterNavMenuPrototypes(sidebarItems); + writeJson(navMenuPath, { prototypes: filtered }); + console.log(`[sync] oneos-prototype-nav: 已更新 nav-menu.json(${filtered.length} 个分区)`); + return true; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const projectRoot = args.projectRoot || path.resolve(__dirname, '..'); + const sidebarItems = loadSidebarPrototypes(projectRoot); + const targets = args.prototype + ? [args.prototype] + : listPrototypeIdsWithAnnotation(projectRoot); + + if (!sidebarItems.length) { + console.error('sidebar-tree.json 中未找到 prototypes 菜单。'); + process.exit(1); + } + if (!targets.length) { + console.log('未找到带 annotation-source.json 的原型。'); + process.exit(0); + } + + let changed = 0; + for (const prototypeId of targets) { + if (cleanupPrototypeDirectory(prototypeId, projectRoot)) { + changed += 1; + } + } + console.log(`完成:处理 ${targets.length} 个原型,清理 ${changed} 个 annotation-source.json`); + syncPrototypeNavMenu(projectRoot, sidebarItems); +} + +main(); diff --git a/scripts/sync-prototype-nav-registry.mjs b/scripts/sync-prototype-nav-registry.mjs new file mode 100644 index 0000000..e8e28e9 --- /dev/null +++ b/scripts/sync-prototype-nav-registry.mjs @@ -0,0 +1,358 @@ +/** + * 原型导航注册表:检测原型变更、递增版本、写入变更日志,并同步 nav-menu。 + * + * 用法: + * node scripts/sync-prototype-nav-registry.mjs + * node scripts/sync-prototype-nav-registry.mjs --prototype vehicle-h2-fee-ledger + * node scripts/sync-prototype-nav-registry.mjs --prototype vehicle-h2-fee-ledger --note "成本单价校验" + * CHANGELOG_NOTE="修复筛选" node scripts/sync-prototype-nav-registry.mjs --prototype customer-management + */ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const NAV_PROTOTYPE_ID = 'oneos-prototype-nav'; +const REGISTRY_RELATIVE = 'src/prototypes/oneos-prototype-nav/prototype-registry.json'; +const TRACKED_EXTENSIONS = new Set(['.tsx', '.ts', '.jsx', '.js', '.css', '.json', '.md', '.html']); +const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', 'dist', 'coverage']); +const IGNORED_FILE_NAMES = new Set(['prototype-registry.json', 'nav-menu.json']); +const MAX_CHANGELOG_PER_PROTOTYPE = 30; +const MAX_RECENT_UPDATES = 40; + +function parseArgs(argv) { + const args = { + prototype: '', + projectRoot: '', + note: process.env.CHANGELOG_NOTE?.trim() || '', + quiet: false, + skipDirectorySync: false, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--prototype' && argv[i + 1]) { + args.prototype = argv[++i].trim(); + } else if (arg === '--project-root' && argv[i + 1]) { + args.projectRoot = path.resolve(argv[++i].trim()); + } else if (arg === '--note' && argv[i + 1]) { + args.note = argv[++i].trim(); + } else if (arg === '--quiet') { + args.quiet = true; + } else if (arg === '--skip-directory-sync') { + args.skipDirectorySync = true; + } + } + return args; +} + +function readJson(filePath, fallback = null) { + if (!fs.existsSync(filePath)) return fallback; + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function log(message, quiet) { + if (!quiet) console.log(message); +} + +function prototypeIdFromItemKey(itemKey) { + const normalized = String(itemKey || '').trim().replace(/^\/+/u, ''); + const match = normalized.match(/^prototypes\/(.+)$/u); + return match ? match[1] : ''; +} + +function loadSidebarPrototypes(projectRoot) { + const sidebarPath = path.join(projectRoot, '.axhub/make/sidebar-tree.json'); + if (!fs.existsSync(sidebarPath)) { + throw new Error(`缺少侧边栏配置:${sidebarPath}`); + } + const sidebar = readJson(sidebarPath, {}); + return Array.isArray(sidebar.prototypes) ? sidebar.prototypes : []; +} + +function collectNavPrototypeTitles(sidebarItems) { + const titles = new Map(); + const walk = (items) => { + for (const item of items || []) { + if (item?.kind === 'item') { + const prototypeId = prototypeIdFromItemKey(item.itemKey); + if (prototypeId && prototypeId !== NAV_PROTOTYPE_ID) { + titles.set(prototypeId, item.title || prototypeId); + } + } + if (Array.isArray(item?.children)) walk(item.children); + } + }; + walk(sidebarItems); + return titles; +} + +function shouldTrackFile(relativePath) { + const base = path.basename(relativePath); + if (IGNORED_FILE_NAMES.has(base)) return false; + if (relativePath === 'annotation-source.json') return false; + const ext = path.extname(relativePath).toLowerCase(); + return TRACKED_EXTENSIONS.has(ext); +} + +function listTrackedFiles(prototypeDir) { + const files = []; + const walk = (dir, prefix = '') => { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (IGNORED_DIR_NAMES.has(entry.name)) continue; + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(abs, rel); + continue; + } + if (shouldTrackFile(rel)) files.push(rel); + } + }; + walk(prototypeDir); + return files.sort((a, b) => a.localeCompare(b)); +} + +function hashPrototypeDir(prototypeDir) { + const files = listTrackedFiles(prototypeDir); + const hash = crypto.createHash('sha256'); + hash.update(`files:${files.length}\n`); + for (const rel of files) { + const abs = path.join(prototypeDir, rel); + const stat = fs.statSync(abs); + hash.update(`${rel}\n${stat.mtimeMs}\n${stat.size}\n`); + hash.update(fs.readFileSync(abs)); + hash.update('\n'); + } + return { hash: hash.digest('hex'), files }; +} + +function formatNowParts(date = new Date()) { + const pad = (n) => String(n).padStart(2, '0'); + return { + iso: date.toISOString(), + date: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + time: `${pad(date.getHours())}:${pad(date.getMinutes())}`, + label: `${date.getMonth() + 1}月${date.getDate()}日`, + }; +} + +function summarizeChange(changedFiles, note) { + if (note) return note; + const names = changedFiles.map((f) => path.basename(f)).join(' '); + if (/PRD|requirements|需求/i.test(names) || changedFiles.some((f) => f.includes('.spec/') && f.endsWith('.md'))) { + return '更新需求说明与标注'; + } + if (changedFiles.some((f) => f.endsWith('.css'))) { + return '更新页面样式'; + } + if (changedFiles.some((f) => /\.(tsx|jsx)$/.test(f) || f.includes('/pages/'))) { + return '更新页面逻辑与交互'; + } + if (changedFiles.length === 1) { + return `更新 ${path.basename(changedFiles[0])}`; + } + return `更新 ${changedFiles.length} 个文件`; +} + +function diffChangedFiles(previousFiles, currentFiles) { + const prevSet = new Set(previousFiles || []); + return currentFiles.filter((file) => !prevSet.has(file)); +} + +/** 仅标注/批注文件变动时不自动递增版本(避免 dev 保存刷屏) */ +function isAnnotationOnlyChange(changedFiles) { + if (!changedFiles.length) return false; + return changedFiles.every((file) => ( + file === 'annotation-source.json' + || file.endsWith('/annotation-source.json') + || file.includes('prototype-comments') + || (file.includes('.spec/') && file.endsWith('.md') && !file.includes('requirements-prd')) + )); +} + +function bumpVersion(revision) { + return `v1.${revision}`; +} + +function createRegistrySkeleton() { + return { + version: 1, + updatedAt: new Date().toISOString(), + prototypes: {}, + recentUpdates: [], + }; +} + +function upsertPrototypeRecord(registry, prototypeId, title, contentHash, files, options) { + const existing = registry.prototypes[prototypeId] || { + title, + version: 'v1.0', + revision: 0, + lastUpdated: null, + contentHash: '', + trackedFiles: [], + changelog: [], + }; + + const changed = existing.contentHash && existing.contentHash !== contentHash; + const now = formatNowParts(); + + if (!existing.contentHash) { + existing.title = title; + existing.contentHash = contentHash; + existing.trackedFiles = files; + existing.lastUpdated = now.iso; + registry.prototypes[prototypeId] = existing; + return { changed: false, entry: null }; + } + + if (!changed) { + existing.title = title; + existing.trackedFiles = files; + registry.prototypes[prototypeId] = existing; + return { changed: false, entry: null }; + } + + const changedFiles = diffChangedFiles(existing.trackedFiles, files); + if (!options.note && isAnnotationOnlyChange(changedFiles.length ? changedFiles : files)) { + existing.title = title; + existing.contentHash = contentHash; + existing.trackedFiles = files; + registry.prototypes[prototypeId] = existing; + return { changed: false, entry: null }; + } + + const summary = summarizeChange(changedFiles.length ? changedFiles : files, options.note); + const nextRevision = (existing.revision || 0) + 1; + const version = bumpVersion(nextRevision); + + const entry = { + version, + date: now.date, + time: now.time, + summary, + files: (changedFiles.length ? changedFiles : files).slice(0, 12), + }; + + existing.title = title; + existing.version = version; + existing.revision = nextRevision; + existing.lastUpdated = now.iso; + existing.contentHash = contentHash; + existing.trackedFiles = files; + existing.changelog = [entry, ...(existing.changelog || [])].slice(0, MAX_CHANGELOG_PER_PROTOTYPE); + registry.prototypes[prototypeId] = existing; + + return { changed: true, entry: { prototypeId, title, ...entry } }; +} + +function pushRecentUpdate(registry, update) { + if (!update) return; + registry.recentUpdates = [ + update, + ...(registry.recentUpdates || []).filter((item) => !( + item.prototypeId === update.prototypeId && item.version === update.version + )), + ].slice(0, MAX_RECENT_UPDATES); +} + +function syncNavMenuAndDirectories(projectRoot, quiet) { + const script = path.join(projectRoot, 'scripts/sync-project-prototype-directory.mjs'); + const result = spawnSync(process.execPath, [script, `--project-root=${projectRoot}`], { + cwd: projectRoot, + encoding: 'utf8', + }); + if (result.status !== 0) { + const err = result.stderr || result.stdout || 'unknown error'; + throw new Error(`同步原型目录失败:${err}`); + } + if (!quiet && result.stdout?.trim()) { + console.log(result.stdout.trim()); + } +} + +function syncXllNavMenu(projectRoot, quiet) { + const script = path.join(projectRoot, 'scripts/sync-xll-nav-menu.mjs'); + if (!fs.existsSync(script)) return false; + const result = spawnSync(process.execPath, [script, `--project-root=${projectRoot}`], { + cwd: projectRoot, + encoding: 'utf8', + }); + if (result.status !== 0) { + const err = result.stderr || result.stdout || 'unknown error'; + throw new Error(`同步小羚羚导航失败:${err}`); + } + if (!quiet && result.stdout?.trim()) { + console.log(result.stdout.trim()); + } + return true; +} + +export function syncPrototypeNavRegistry(options = {}) { + const projectRoot = options.projectRoot || path.resolve(__dirname, '..'); + const registryPath = path.join(projectRoot, REGISTRY_RELATIVE); + const sidebarItems = loadSidebarPrototypes(projectRoot); + const titleMap = collectNavPrototypeTitles(sidebarItems); + const registry = readJson(registryPath, createRegistrySkeleton()) || createRegistrySkeleton(); + + if (!options.skipDirectorySync) { + syncNavMenuAndDirectories(projectRoot, options.quiet); + syncXllNavMenu(projectRoot, options.quiet); + } + + const targetIds = options.prototype + ? [options.prototype] + : [...titleMap.keys()]; + + let bumped = 0; + for (const prototypeId of targetIds) { + if (prototypeId === NAV_PROTOTYPE_ID && options.prototype !== NAV_PROTOTYPE_ID) continue; + const title = titleMap.get(prototypeId) || prototypeId; + const prototypeDir = path.join(projectRoot, 'src/prototypes', prototypeId); + if (!fs.existsSync(prototypeDir)) { + log(`[skip] ${prototypeId}: 目录不存在`, options.quiet); + continue; + } + + const { hash, files } = hashPrototypeDir(prototypeDir); + const result = upsertPrototypeRecord(registry, prototypeId, title, hash, files, { + note: options.note && options.prototype === prototypeId ? options.note : '', + }); + if (result.changed) { + bumped += 1; + pushRecentUpdate(registry, result.entry); + log(`[version] ${prototypeId}: ${result.entry.version} — ${result.entry.summary}`, options.quiet); + } + } + + registry.updatedAt = new Date().toISOString(); + writeJson(registryPath, registry); + + return { bumped, registryPath, prototypeCount: targetIds.length }; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const projectRoot = args.projectRoot || path.resolve(__dirname, '..'); + const result = syncPrototypeNavRegistry({ + projectRoot, + prototype: args.prototype, + note: args.note, + quiet: args.quiet, + skipDirectorySync: args.skipDirectorySync, + }); + log(`完成:检查 ${result.prototypeCount} 个原型,新增版本 ${result.bumped} 个`, args.quiet); +} + +const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + main(); +} diff --git a/scripts/sync-vendor-if-present.mjs b/scripts/sync-vendor-if-present.mjs new file mode 100644 index 0000000..62593eb --- /dev/null +++ b/scripts/sync-vendor-if-present.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { runCommandSync } from './utils/command-runtime.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const clientRoot = path.resolve(__dirname, '..'); + +function findWorkspaceRoot(startDir) { + let current = path.resolve(startDir); + while (true) { + if (fs.existsSync(path.join(current, 'pnpm-workspace.yaml'))) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +function hasMakePackage(workspaceRoot) { + try { + const packageJson = JSON.parse(fs.readFileSync(path.join(workspaceRoot, 'apps', 'axhub-make', 'package.json'), 'utf8')); + return packageJson?.name === '@axhub/make'; + } catch { + return false; + } +} + +const workspaceRoot = findWorkspaceRoot(clientRoot); +if (!workspaceRoot || !hasMakePackage(workspaceRoot)) { + process.exit(0); +} + +const result = runCommandSync({ + command: 'pnpm', + args: ['--filter', '@axhub/make', 'vendor:sync'], + cwd: workspaceRoot, + timeoutMs: 10 * 60 * 1000, + maxBuffer: 20 * 1024 * 1024, +}); + +if (result.status !== 0) { + const stderr = result.stderr.trim(); + const stdout = result.stdout.trim(); + process.stderr.write(`${stderr || stdout || 'Failed to sync Make vendor packages'}\n`); + process.exit(result.status ?? 1); +} diff --git a/scripts/sync-xll-nav-menu.mjs b/scripts/sync-xll-nav-menu.mjs new file mode 100644 index 0000000..567e2b0 --- /dev/null +++ b/scripts/sync-xll-nav-menu.mjs @@ -0,0 +1,132 @@ +/** + * 从小羚羚「小程序」项目同步页面目录到 oneos 原型导航。 + * + * 用法: + * node scripts/sync-xll-nav-menu.mjs + * node scripts/sync-xll-nav-menu.mjs --project-root /path/to/oneos1.2 + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const ROUTE_TO_PAGE = { + todo: 'todo', + business: 'business', + map: 'map', + mine: 'mine', + audit: 'audit', + delivery: 'delivery', + inspection: 'inspection', + vehicle: 'vehicle', + thirdReturn: 'third-return', + 'third-return': 'third-return', + replace: 'replace', + 'audit-return': 'audit-return', + training: 'driver-training', + 'driver-training': 'driver-training', +}; + +function parseArgs(argv) { + const args = { projectRoot: '' }; + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === '--project-root' && argv[i + 1]) { + args.projectRoot = path.resolve(argv[++i].trim()); + } + } + return args; +} + +function readJson(filePath, fallback = null) { + if (!fs.existsSync(filePath)) return fallback; + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function resolvePageId(route) { + const key = String(route || '').trim(); + return ROUTE_TO_PAGE[key] || key; +} + +function buildLinksFromFolder(folder) { + const links = []; + for (const child of folder.children || []) { + if (child?.type !== 'route' || !child.route) continue; + const pageId = resolvePageId(child.route); + links.push({ + id: child.id || `xll-route-${pageId}`, + title: child.title || pageId, + pageId, + route: child.route, + }); + } + return links; +} + +export function syncXllNavMenu(projectRoot) { + const oneosRoot = projectRoot || path.resolve(__dirname, '..'); + const sourceConfigPath = path.join(oneosRoot, 'src/prototypes/oneos-prototype-nav/xll-nav-source.json'); + const outputPath = path.join(oneosRoot, 'src/prototypes/oneos-prototype-nav/xll-nav-menu.json'); + const sourceConfig = readJson(sourceConfigPath); + if (!sourceConfig?.projectRoot) { + throw new Error(`缺少小羚羚同步配置:${sourceConfigPath}`); + } + + const xllRoot = path.resolve(sourceConfig.projectRoot); + const annotationPath = path.join(xllRoot, sourceConfig.annotationSource); + const annotation = readJson(annotationPath); + if (!annotation) { + throw new Error(`无法读取小羚羚标注目录:${annotationPath}`); + } + + const includeIds = new Set(sourceConfig.includeDirectoryIds || ['directory-main', 'directory-modules']); + const groups = []; + for (const node of annotation.directory?.nodes || []) { + if (node?.type !== 'folder' || !includeIds.has(node.id)) continue; + const links = buildLinksFromFolder(node); + if (!links.length) continue; + groups.push({ + id: node.id, + title: node.title || '分组', + defaultExpanded: node.defaultExpanded !== false, + links, + }); + } + + const devInfoPath = path.join(xllRoot, sourceConfig.devServerInfo || '.axhub/make/.dev-server-info.json'); + const devInfo = readJson(devInfoPath, {}); + const runtimeOrigin = String(devInfo.origin || '').trim() || 'http://localhost:51721'; + const prototypeId = sourceConfig.prototypeId || 'xll-miniapp'; + + const menu = { + version: 1, + updatedAt: new Date().toISOString(), + title: '小羚羚', + sectionId: 'folder-prototypes-xll-miniapp', + description: '氢能车辆运营移动端原型;菜单与小羚羚「小程序」项目目录同步。', + prototypeId, + runtimeOrigin, + hrefPrefix: `${runtimeOrigin.replace(/\/$/u, '')}/prototypes/${prototypeId}`, + groups, + }; + + writeJson(outputPath, menu); + return { outputPath, groupCount: groups.length, linkCount: groups.reduce((n, g) => n + g.links.length, 0) }; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const result = syncXllNavMenu(args.projectRoot); + console.log(`[sync] xll-nav-menu: ${result.groupCount} 个分组,${result.linkCount} 个页面入口`); + console.log(`[sync] 写入 ${result.outputPath}`); +} + +const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + main(); +} diff --git a/scripts/templates/empty-canvas.code-manifest.json b/scripts/templates/empty-canvas.code-manifest.json new file mode 100644 index 0000000..69957d4 --- /dev/null +++ b/scripts/templates/empty-canvas.code-manifest.json @@ -0,0 +1,593 @@ +{ + "command": "inspect", + "generatedAt": "2026-04-01T10:21:53.845Z", + "figPath": "scripts/templates/empty-canvas.fig", + "archive": { + "prelude": "fig-make", + "version": 101, + "parts": 2 + }, + "sourceRoot": "src", + "summary": { + "totalCodeFiles": 63, + "pathCounts": { + "(root)": 2, + "components": 5, + "components/figma": 1, + "components/mockups": 5, + "components/ui": 48, + "guidelines": 1, + "styles": 1 + }, + "duplicateGroups": [] + }, + "entries": [ + { + "nodeChangeIndex": 7, + "name": "App.tsx", + "codeFilePath": null, + "logicalPath": "App.tsx", + "sourceCodeSha1": "08dbcc7fa8dfe3f4d85267eaee09a1b86ce3ab3e", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 9, + "name": "accordion.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/accordion.tsx", + "sourceCodeSha1": "96e2ea4a76d985018b49b3f26c18bd060992ba1c", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 10, + "name": "alert-dialog.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/alert-dialog.tsx", + "sourceCodeSha1": "4499b93fee3f76e8a2cfee3e04ad9acc14e914f8", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 11, + "name": "alert.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/alert.tsx", + "sourceCodeSha1": "c4c43c93ca441a0cc96160fcbddc367b42bd0785", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 12, + "name": "aspect-ratio.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/aspect-ratio.tsx", + "sourceCodeSha1": "da69e8483774b6cb06c9f1109f6c5481846fa373", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 13, + "name": "avatar.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/avatar.tsx", + "sourceCodeSha1": "acca40a2a8a7b9e8b16d56e34722622875ce9279", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 14, + "name": "badge.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/badge.tsx", + "sourceCodeSha1": "2bed0297b71935fb53c36665a93713017b4efba0", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 15, + "name": "breadcrumb.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/breadcrumb.tsx", + "sourceCodeSha1": "2607a19685b58acec5df7cf79cf3f319860f10d0", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 16, + "name": "button.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/button.tsx", + "sourceCodeSha1": "8cc739da9862b0c46a941f2c2d3c04e88ed14f6e", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 17, + "name": "calendar.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/calendar.tsx", + "sourceCodeSha1": "dcf6f206c549acd3d3d93938af700040cf4736b3", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 18, + "name": "card.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/card.tsx", + "sourceCodeSha1": "368b7c30660e2357f967712258eb04a947d105e6", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 19, + "name": "carousel.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/carousel.tsx", + "sourceCodeSha1": "b18b2ad961ae8df8504cd08af7049da628cfcc84", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 20, + "name": "chart.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/chart.tsx", + "sourceCodeSha1": "e2fa2b6b71b5b989b07d655d0311b0b656370868", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 21, + "name": "checkbox.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/checkbox.tsx", + "sourceCodeSha1": "031623c953f24511c2b364736dc0c29bbd72b0da", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 22, + "name": "collapsible.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/collapsible.tsx", + "sourceCodeSha1": "c4a50eb76c7aa18863f1be0fcfb77a39c555dea5", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 23, + "name": "command.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/command.tsx", + "sourceCodeSha1": "e2bacd22ead0ead7c35ae4ea81cc063648be5551", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 24, + "name": "context-menu.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/context-menu.tsx", + "sourceCodeSha1": "dcc0f2c734146df4fdda300fb88fc4b778a8e943", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 25, + "name": "dialog.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/dialog.tsx", + "sourceCodeSha1": "d2e2d438b7ec8f4655cb363bff0b51c362403679", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 26, + "name": "drawer.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/drawer.tsx", + "sourceCodeSha1": "77956c0b7663cbe3de836e37395bc58bd441e662", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 27, + "name": "dropdown-menu.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/dropdown-menu.tsx", + "sourceCodeSha1": "f261713be8c0469a202d6571e667dced43443dc1", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 28, + "name": "form.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/form.tsx", + "sourceCodeSha1": "b35ebd81d98a9f3a1ab62fba289883715f1d1d21", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 29, + "name": "hover-card.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/hover-card.tsx", + "sourceCodeSha1": "b72c3f65947cd7593c1a89a354fa1d6c338f36e9", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 30, + "name": "input-otp.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/input-otp.tsx", + "sourceCodeSha1": "71c2917cc4fe8bb4c80b11323909cf710a93d0c6", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 31, + "name": "input.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/input.tsx", + "sourceCodeSha1": "e9f0dfef25dba4b04ad97609243106533bc078dc", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 32, + "name": "label.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/label.tsx", + "sourceCodeSha1": "8c069a29a97703512a9c930d5c4014476834cc96", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 33, + "name": "menubar.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/menubar.tsx", + "sourceCodeSha1": "ea4c2aa51da161eeb2298dfbc5e70308022214e7", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 34, + "name": "navigation-menu.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/navigation-menu.tsx", + "sourceCodeSha1": "0d7f19cd8946a82063e8d98d3709903326db0aad", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 35, + "name": "pagination.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/pagination.tsx", + "sourceCodeSha1": "a259b6b07136d542c95ba8bd37d9337376acf65b", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 36, + "name": "popover.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/popover.tsx", + "sourceCodeSha1": "762244296d854ac1ba4015cdb7fcbd9db5ef1754", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 37, + "name": "progress.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/progress.tsx", + "sourceCodeSha1": "1a41de261cc00b1eb94b36077854ad9ed3c18804", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 38, + "name": "radio-group.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/radio-group.tsx", + "sourceCodeSha1": "650cb3abc42c9a1664e27e7b47eb09b76c6a4e0d", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 39, + "name": "resizable.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/resizable.tsx", + "sourceCodeSha1": "588fb45baae2adbcec07d32a0e59476878621ca7", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 40, + "name": "scroll-area.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/scroll-area.tsx", + "sourceCodeSha1": "a86380f1aed911b34917005ba4f9949642910dbf", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 41, + "name": "select.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/select.tsx", + "sourceCodeSha1": "37fa9c4ceda7950664c385c9ef27ba1bbee47bde", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 42, + "name": "separator.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/separator.tsx", + "sourceCodeSha1": "979129951eb27258e96545382eaa9e37b9de1209", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 43, + "name": "sheet.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/sheet.tsx", + "sourceCodeSha1": "4f25f42b08ca91c04c4510cc16eb020a9c7f9587", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 44, + "name": "sidebar.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/sidebar.tsx", + "sourceCodeSha1": "f79dfb3dbeef2d211510e47f9ac70f5cf33f37cd", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 45, + "name": "skeleton.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/skeleton.tsx", + "sourceCodeSha1": "0662ecc7ab4b6c1746d91e009ef88d219005448d", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 46, + "name": "slider.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/slider.tsx", + "sourceCodeSha1": "23b80e2e9a0da874dcf78e56feb54346fe2bd0bb", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 47, + "name": "sonner.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/sonner.tsx", + "sourceCodeSha1": "72058ff9b056b79f72f79dfbbcf2db164a79e4ab", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 48, + "name": "switch.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/switch.tsx", + "sourceCodeSha1": "f83deec5e477bd7ca727eea401354d83ff22f6b5", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 49, + "name": "table.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/table.tsx", + "sourceCodeSha1": "ee1282461b1b247aa28520bb5cd6ce716ac1b468", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 50, + "name": "tabs.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/tabs.tsx", + "sourceCodeSha1": "40469be2dacfc3fc86909433bafd1d92bf7616d6", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 51, + "name": "textarea.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/textarea.tsx", + "sourceCodeSha1": "d608dfd62677493c7361908d510ef7d42a49c212", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 52, + "name": "toggle-group.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/toggle-group.tsx", + "sourceCodeSha1": "7b49c924ad02e9ffc94a0be4fab7f92caab54552", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 53, + "name": "toggle.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/toggle.tsx", + "sourceCodeSha1": "82d3925cf6ee63d4092d366e01ad478ad7d2f325", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 54, + "name": "tooltip.tsx", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/tooltip.tsx", + "sourceCodeSha1": "8e78e17fad045f62a35dabc021fb7477849ac93a", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 55, + "name": "use-mobile.ts", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/use-mobile.ts", + "sourceCodeSha1": "b1102c4af2fef644861e5df108eb5b133dc71805", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 56, + "name": "utils.ts", + "codeFilePath": "components/ui", + "logicalPath": "components/ui/utils.ts", + "sourceCodeSha1": "f095b349a6d6cbcbf7bcabdb2f51b3095e7403f0", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 57, + "name": "ImageWithFallback.tsx", + "codeFilePath": "components/figma", + "logicalPath": "components/figma/ImageWithFallback.tsx", + "sourceCodeSha1": "e93040f24666348383e937031a95b102570d13ec", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 58, + "name": "globals.css", + "codeFilePath": "styles", + "logicalPath": "styles/globals.css", + "sourceCodeSha1": "70abb71fd97a750fb4ba7c1b892f2b70d8d611d5", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 59, + "name": "Guidelines.md", + "codeFilePath": "guidelines", + "logicalPath": "guidelines/Guidelines.md", + "sourceCodeSha1": "11f6b0e620fa939a50999dada1539096198d9a3b", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 60, + "name": "Dashboard.tsx", + "codeFilePath": "components", + "logicalPath": "components/Dashboard.tsx", + "sourceCodeSha1": "9f8d3c852c0752919d2f1725be3c902902b20971", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 61, + "name": "AddExpense.tsx", + "codeFilePath": "components", + "logicalPath": "components/AddExpense.tsx", + "sourceCodeSha1": "dcc8ea2bcab75e0c954784d40e8cbfb2e2ee74b7", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 62, + "name": "Analytics.tsx", + "codeFilePath": "components", + "logicalPath": "components/Analytics.tsx", + "sourceCodeSha1": "961d99d46e2e7c2b475a77783a098bd48efc27b8", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 63, + "name": "SearchFilter.tsx", + "codeFilePath": "components", + "logicalPath": "components/SearchFilter.tsx", + "sourceCodeSha1": "7dd9d547b69d19e3390564e6387039bc067891af", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 64, + "name": "SettingsProfile.tsx", + "codeFilePath": "components", + "logicalPath": "components/SettingsProfile.tsx", + "sourceCodeSha1": "806456c50af5a3f7d302b75a21d9d784f59b4f7d", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 65, + "name": "Attributions.md", + "codeFilePath": null, + "logicalPath": "Attributions.md", + "sourceCodeSha1": "4f53c7de5eb7d707eea56961544b22433e1a86a3", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 67, + "name": "DashboardMockup.tsx", + "codeFilePath": "components/mockups", + "logicalPath": "components/mockups/DashboardMockup.tsx", + "sourceCodeSha1": "61b29d59129500144886e0cc403f491d49eac3e5", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 68, + "name": "AddExpenseMockup.tsx", + "codeFilePath": "components/mockups", + "logicalPath": "components/mockups/AddExpenseMockup.tsx", + "sourceCodeSha1": "e5d430c751a626680cf8bca5411234bb9dfd8adb", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 69, + "name": "AnalyticsMockup.tsx", + "codeFilePath": "components/mockups", + "logicalPath": "components/mockups/AnalyticsMockup.tsx", + "sourceCodeSha1": "f94a844fbe540becde9c6314c04867d13b94461c", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 70, + "name": "SearchMockup.tsx", + "codeFilePath": "components/mockups", + "logicalPath": "components/mockups/SearchMockup.tsx", + "sourceCodeSha1": "05442b617000ddd07d81961858d1bcd2b13c7509", + "isDuplicate": false, + "duplicateCount": 1 + }, + { + "nodeChangeIndex": 71, + "name": "SettingsMockup.tsx", + "codeFilePath": "components/mockups", + "logicalPath": "components/mockups/SettingsMockup.tsx", + "sourceCodeSha1": "7a92a34c001a2c6be42031acf36eb657ce3810de", + "isDuplicate": false, + "duplicateCount": 1 + } + ] +} diff --git a/scripts/templates/empty-canvas.fig b/scripts/templates/empty-canvas.fig new file mode 100644 index 0000000..3c9c44e Binary files /dev/null and b/scripts/templates/empty-canvas.fig differ diff --git a/scripts/utils/command-runtime.d.ts b/scripts/utils/command-runtime.d.ts new file mode 100644 index 0000000..0ace583 --- /dev/null +++ b/scripts/utils/command-runtime.d.ts @@ -0,0 +1,63 @@ +export type DecodeOutputOptions = { + platform?: NodeJS.Platform; +}; + +export type RunCommandOptions = { + command: string; + args?: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + timeoutMs?: number; + detached?: boolean; + capture?: boolean; + stdio?: any; +}; + +export type RunCommandResult = { + command: string; + args: string[]; + spawnCommand: string; + spawnArgs: string[]; + code: number | null; + signal: NodeJS.Signals | null; + stdoutBuffer: Buffer; + stderrBuffer: Buffer; + stdout: string; + stderr: string; +}; + +export type RunCommandSyncOptions = { + command: string; + args?: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + timeoutMs?: number; + maxBuffer?: number; +}; + +export type RunCommandSyncResult = { + command: string; + args: string[]; + spawnCommand: string; + spawnArgs: string[]; + status: number | null; + signal: NodeJS.Signals | null; + error: Error | null; + stdoutBuffer: Buffer; + stderrBuffer: Buffer; + stdout: string; + stderr: string; +}; + +export function decodeOutput(value: unknown, options?: DecodeOutputOptions): string; +export function runCommand(options: RunCommandOptions): Promise; +export function runCommandSync(options: RunCommandSyncOptions): RunCommandSyncResult; +export function commandExists(command: string): boolean; +export function getPreferredNpmCommand(): string; +export function getPreferredNpxCommand(): string; +export function getSpawnCommandSpec(command: string, args?: string[], platform?: NodeJS.Platform): { + command: string; + args: string[]; + windowsHide: boolean; +}; +export function __resetWindowsCodePageCacheForTests(): void; diff --git a/scripts/utils/command-runtime.mjs b/scripts/utils/command-runtime.mjs new file mode 100644 index 0000000..0c9778c --- /dev/null +++ b/scripts/utils/command-runtime.mjs @@ -0,0 +1,340 @@ +import { spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import iconv from 'iconv-lite'; + +const WINDOWS_CODEPAGE_TIMEOUT_MS = 1200; +let cachedWindowsCodePage = null; + +function getPlatform(overridePlatform) { + return overridePlatform || process.platform; +} + +function quoteForCmdExec(value) { + if (!value) return '""'; + if (!/[\s"&^|<>]/.test(value)) return value; + const escaped = String(value) + .replace(/(\\*)"/g, '$1$1\\"') + .replace(/(\\+)$/g, '$1$1'); + return `"${escaped}"`; +} + +function buildWindowsCommandLine(command, args) { + return [command, ...args].map((part) => quoteForCmdExec(String(part))).join(' '); +} + +function getEnvValue(env, key) { + if (!env) return undefined; + + const direct = env[key]; + if (typeof direct === 'string' && direct.length > 0) { + return direct; + } + + const matchedKey = Object.keys(env).find((candidate) => candidate.toLowerCase() === key.toLowerCase()); + if (!matchedKey) return undefined; + + const value = env[matchedKey]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function getWindowsPathExtList(env) { + const pathExt = getEnvValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD'; + return pathExt + .split(';') + .map((ext) => ext.trim()) + .filter(Boolean) + .map((ext) => (ext.startsWith('.') ? ext.toLowerCase() : `.${ext.toLowerCase()}`)); +} + +function resolveWindowsCommand(command, env) { + if (!command || typeof command !== 'string') return command; + + const trimmed = command.trim(); + if (!trimmed) return trimmed; + + const hasPathSeparator = /[\\/]/.test(trimmed); + const ext = path.extname(trimmed); + const pathExts = ext ? [''] : getWindowsPathExtList(env); + + const candidateDirs = hasPathSeparator + ? [''] + : (getEnvValue(env, 'PATH') || '') + .split(';') + .map((entry) => entry.trim()) + .filter(Boolean); + + const baseCandidates = hasPathSeparator ? [trimmed] : candidateDirs.map((dir) => path.join(dir, trimmed)); + + for (const baseCandidate of baseCandidates) { + const suffixes = ext ? [''] : pathExts; + for (const suffix of suffixes) { + const fullPath = suffix ? `${baseCandidate}${suffix}` : baseCandidate; + if (fs.existsSync(fullPath)) { + return fullPath; + } + } + } + + return trimmed; +} + +function shouldUseWindowsCmdWrapper(platform, command) { + if (platform !== 'win32') return false; + return /\.(cmd|bat)$/i.test(command) || !/\.(exe|com)$/i.test(command); +} + +function getSpawnSpec(command, args, platform = process.platform, env = process.env) { + const resolvedCommand = platform === 'win32' ? resolveWindowsCommand(command, env) : command; + + if (!shouldUseWindowsCmdWrapper(platform, resolvedCommand)) { + return { + command: resolvedCommand, + args, + windowsHide: platform === 'win32', + }; + } + + const commandLine = buildWindowsCommandLine(resolvedCommand, args); + return { + command: 'cmd.exe', + args: ['/d', '/s', '/c', commandLine], + windowsHide: true, + }; +} + +function mapCodePageToEncoding(codePage) { + if (codePage === 65001) return 'utf8'; + if (codePage === 936) return 'gbk'; + if (codePage === 54936) return 'gb18030'; + return 'gb18030'; +} + +function parseWindowsCodePage(text) { + if (!text) return null; + const match = String(text).match(/(\d{3,5})/); + if (!match) return null; + const codePage = Number(match[1]); + return Number.isFinite(codePage) ? codePage : null; +} + +function readWindowsCodePageSync() { + if (cachedWindowsCodePage !== null) { + return cachedWindowsCodePage; + } + + try { + const result = spawnSync('cmd.exe', ['/d', '/s', '/c', 'chcp'], { + windowsHide: true, + encoding: 'utf8', + timeout: WINDOWS_CODEPAGE_TIMEOUT_MS, + }); + + const output = `${result.stdout || ''}\n${result.stderr || ''}`; + cachedWindowsCodePage = parseWindowsCodePage(output); + } catch { + cachedWindowsCodePage = null; + } + + return cachedWindowsCodePage; +} + +function toBuffer(value) { + if (!value) return Buffer.alloc(0); + if (Buffer.isBuffer(value)) return value; + if (typeof value === 'string') return Buffer.from(value); + if (value instanceof Uint8Array) return Buffer.from(value); + return Buffer.from(String(value)); +} + +export function decodeOutput(value, options = {}) { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value; + + const platform = getPlatform(options.platform); + const buffer = toBuffer(value); + if (buffer.length === 0) return ''; + + try { + const strictUtf8 = new TextDecoder('utf-8', { fatal: true }).decode(buffer); + return strictUtf8; + } catch { + // Fall through to platform fallback decoder. + } + + if (platform === 'win32') { + const activeCodePage = readWindowsCodePageSync(); + const preferredEncoding = mapCodePageToEncoding(activeCodePage); + + try { + return iconv.decode(buffer, preferredEncoding); + } catch { + // Fall through to generic fallback. + } + + try { + return iconv.decode(buffer, 'gb18030'); + } catch { + // Fall through to latin1 fallback. + } + } + + try { + return buffer.toString('utf8'); + } catch { + return buffer.toString('latin1'); + } +} + +export function runCommandSync(options) { + const { + command, + args = [], + cwd, + env, + timeoutMs, + maxBuffer, + } = options; + + const platform = process.platform; + const mergedEnv = env ? { ...process.env, ...env } : process.env; + const spawnSpec = getSpawnSpec(command, args, platform, mergedEnv); + const result = spawnSync(spawnSpec.command, spawnSpec.args, { + cwd, + env: mergedEnv, + timeout: timeoutMs, + maxBuffer, + windowsHide: spawnSpec.windowsHide, + encoding: null, + }); + + const stdoutBuffer = toBuffer(result.stdout); + const stderrBuffer = toBuffer(result.stderr); + + return { + command, + args, + spawnCommand: spawnSpec.command, + spawnArgs: spawnSpec.args, + status: typeof result.status === 'number' ? result.status : null, + signal: result.signal || null, + error: result.error || null, + stdoutBuffer, + stderrBuffer, + stdout: decodeOutput(stdoutBuffer, { platform }), + stderr: decodeOutput(stderrBuffer, { platform }), + }; +} + +export function runCommand(options) { + const { + command, + args = [], + cwd, + env, + timeoutMs, + detached = false, + capture = true, + stdio, + } = options; + + return new Promise((resolve, reject) => { + const platform = process.platform; + const mergedEnv = env ? { ...process.env, ...env } : process.env; + const spawnSpec = getSpawnSpec(command, args, platform, mergedEnv); + + const child = spawn(spawnSpec.command, spawnSpec.args, { + cwd, + env: mergedEnv, + detached, + stdio: stdio || (capture ? ['ignore', 'pipe', 'pipe'] : 'inherit'), + windowsHide: spawnSpec.windowsHide, + }); + + const stdoutChunks = []; + const stderrChunks = []; + + if (capture && child.stdout) { + child.stdout.on('data', (chunk) => { + stdoutChunks.push(toBuffer(chunk)); + }); + } + + if (capture && child.stderr) { + child.stderr.on('data', (chunk) => { + stderrChunks.push(toBuffer(chunk)); + }); + } + + let timeoutId = null; + if (typeof timeoutMs === 'number' && timeoutMs > 0) { + timeoutId = setTimeout(() => { + child.kill('SIGTERM'); + }, timeoutMs); + } + + child.once('error', (error) => { + if (timeoutId) clearTimeout(timeoutId); + reject(error); + }); + + child.once('close', (code, signal) => { + if (timeoutId) clearTimeout(timeoutId); + + const stdoutBuffer = Buffer.concat(stdoutChunks); + const stderrBuffer = Buffer.concat(stderrChunks); + + resolve({ + command, + args, + spawnCommand: spawnSpec.command, + spawnArgs: spawnSpec.args, + code: typeof code === 'number' ? code : null, + signal: signal || null, + stdoutBuffer, + stderrBuffer, + stdout: decodeOutput(stdoutBuffer, { platform }), + stderr: decodeOutput(stderrBuffer, { platform }), + }); + }); + }); +} + +export function commandExists(command) { + if (!command || typeof command !== 'string') { + return false; + } + + const checker = process.platform === 'win32' ? 'where' : 'which'; + const result = runCommandSync({ + command: checker, + args: [command], + timeoutMs: 2000, + }); + + return result.status === 0; +} + +export function getPreferredNpmCommand() { + if (process.platform !== 'win32') { + return 'npm'; + } + + return commandExists('npm.cmd') ? 'npm.cmd' : 'npm'; +} + +export function getPreferredNpxCommand() { + if (process.platform !== 'win32') { + return 'npx'; + } + + return commandExists('npx.cmd') ? 'npx.cmd' : 'npx'; +} + +export function getSpawnCommandSpec(command, args = [], platform = process.platform) { + return getSpawnSpec(command, args, platform); +} + +export function __resetWindowsCodePageCacheForTests() { + cachedWindowsCodePage = null; +} diff --git a/scripts/utils/generatedTsxValidator.mjs b/scripts/utils/generatedTsxValidator.mjs new file mode 100644 index 0000000..4e0c39f --- /dev/null +++ b/scripts/utils/generatedTsxValidator.mjs @@ -0,0 +1,41 @@ +import ts from 'typescript'; + +function formatDiagnostic(diagnostic) { + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); + if (!diagnostic.file || typeof diagnostic.start !== 'number') { + return message; + } + + const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); + const sourceLine = diagnostic.file.text.split(/\r?\n/u)[line] || ''; + return `${diagnostic.file.fileName}:${line + 1}:${character + 1} - ${message}\n${sourceLine}`; +} + +export function validateGeneratedTsx(source, filePath = 'generated.tsx') { + const result = ts.transpileModule(source, { + fileName: filePath, + reportDiagnostics: true, + compilerOptions: { + jsx: ts.JsxEmit.ReactJSX, + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ESNext, + }, + }); + + const diagnostics = (result.diagnostics || []).filter( + (diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error, + ); + + return { + ok: diagnostics.length === 0, + diagnostics, + formatted: diagnostics.map(formatDiagnostic).join('\n\n'), + }; +} + +export function assertValidGeneratedTsx(source, filePath = 'generated.tsx') { + const validation = validateGeneratedTsx(source, filePath); + if (!validation.ok) { + throw new Error(`生成的 TSX 语法校验失败:\n${validation.formatted}`); + } +} diff --git a/scripts/utils/serverInfo.d.ts b/scripts/utils/serverInfo.d.ts new file mode 100644 index 0000000..565134b --- /dev/null +++ b/scripts/utils/serverInfo.d.ts @@ -0,0 +1,26 @@ +export type AxhubServerRole = 'runtime' | 'admin'; + +export interface AxhubServerInfo { + pid: number; + port: number; + host: string; + origin: string; + projectRoot: string; + startedAt: string; +} + +export interface ServerInfoPathOptions { + homeDir?: string; +} + +export function readServerInfo(projectRoot: string, role: AxhubServerRole, options?: ServerInfoPathOptions): AxhubServerInfo | null; +export function getAdminServerInfoPath(projectRoot?: string, options?: ServerInfoPathOptions): string; +export function getRuntimeServerInfoPath(projectRoot: string): string; +export function writeServerInfo( + projectRoot: string, + role: AxhubServerRole, + info: AxhubServerInfo, + options?: ServerInfoPathOptions, +): AxhubServerInfo; +export function fetchHealth(origin: string, timeoutMs?: number): Promise; +export function normalizeHealthServerInfo(data: unknown): AxhubServerInfo | null; diff --git a/scripts/utils/serverInfo.mjs b/scripts/utils/serverInfo.mjs new file mode 100644 index 0000000..a75199a --- /dev/null +++ b/scripts/utils/serverInfo.mjs @@ -0,0 +1,108 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const MAKE_STATE_DIR = path.join('.axhub', 'make'); +const RUNTIME_SERVER_INFO_RELATIVE_PATH = path.join(MAKE_STATE_DIR, '.dev-server-info.json'); +const ADMIN_SERVER_INFO_RELATIVE_PATH = path.join(MAKE_STATE_DIR, '.admin-server-info.json'); +const MAKE_HOME_DIR_ENV = 'AXHUB_MAKE_HOME_DIR'; + +function resolveProjectRoot(projectRoot) { + return path.resolve(projectRoot); +} + +function getServerInfoPath(projectRoot, role) { + if (role === 'admin') { + return getAdminServerInfoPath(); + } + return path.join(resolveProjectRoot(projectRoot), RUNTIME_SERVER_INFO_RELATIVE_PATH); +} + +function getGlobalHomeDir(options = {}) { + return options.homeDir || process.env[MAKE_HOME_DIR_ENV] || os.homedir(); +} + +export function getAdminServerInfoPath(_projectRoot, options = {}) { + return path.join(path.resolve(getGlobalHomeDir(options)), ADMIN_SERVER_INFO_RELATIVE_PATH); +} + +export function getRuntimeServerInfoPath(projectRoot) { + return getServerInfoPath(projectRoot, 'runtime'); +} + +function normalizeServerInfo(data) { + if (!data || typeof data !== 'object') { + return null; + } + if ( + typeof data.pid !== 'number' + || typeof data.port !== 'number' + || typeof data.host !== 'string' + || typeof data.origin !== 'string' + || typeof data.projectRoot !== 'string' + || typeof data.startedAt !== 'string' + ) { + return null; + } + return { + pid: data.pid, + port: data.port, + host: data.host, + origin: data.origin, + projectRoot: resolveProjectRoot(data.projectRoot), + startedAt: data.startedAt, + }; +} + +export function readServerInfo(projectRoot, role, options = {}) { + const infoPath = role === 'admin' + ? getAdminServerInfoPath(projectRoot, options) + : getServerInfoPath(projectRoot, role); + if (!fs.existsSync(infoPath)) { + return null; + } + try { + return normalizeServerInfo(JSON.parse(fs.readFileSync(infoPath, 'utf8'))); + } catch { + return null; + } +} + +export function writeServerInfo(projectRoot, role, info, options = {}) { + const normalized = { + ...info, + projectRoot: resolveProjectRoot(info.projectRoot), + }; + const infoPath = role === 'admin' + ? getAdminServerInfoPath(projectRoot, options) + : getServerInfoPath(projectRoot, role); + fs.mkdirSync(path.dirname(infoPath), { recursive: true }); + fs.writeFileSync(infoPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8'); + return normalized; +} + +export async function fetchHealth(origin, timeoutMs = 1000) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(new URL('/api/health', origin), { + signal: controller.signal, + headers: { accept: 'application/json' }, + }); + if (!response.ok) { + return null; + } + return await response.json(); + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + +export function normalizeHealthServerInfo(data) { + if (!data || typeof data !== 'object') { + return null; + } + return normalizeServerInfo(data.server ?? data); +} diff --git a/scripts/v0-converter.mjs b/scripts/v0-converter.mjs new file mode 100644 index 0000000..4e92f54 --- /dev/null +++ b/scripts/v0-converter.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; + +function normalizeSlashes(input) { + return String(input || '').replace(/\\/g, '/'); +} + +function sanitizeName(rawName) { + return String(rawName || '') + .replace(/[^a-z0-9-]/gi, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .toLowerCase(); +} + +function parseArgs(argv) { + const args = [...argv]; + const projectDirArg = args.shift(); + const outputNameArg = args.shift(); + let targetType = 'prototypes'; + let projectRoot = process.cwd(); + let outputBaseDir = ''; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--target-type') { + targetType = String(args[index + 1] || '').trim(); + index += 1; + } else if (arg === '--project-root') { + projectRoot = path.resolve(args[index + 1] || projectRoot); + index += 1; + } else if (arg === '--output-base-dir') { + outputBaseDir = path.resolve(args[index + 1] || ''); + index += 1; + } + } + + if (!projectDirArg) throw new Error('Missing project directory'); + const outputName = sanitizeName(outputNameArg || path.basename(projectDirArg)); + if (!outputName) throw new Error('Missing valid output name'); + return { + projectDir: path.resolve(projectRoot, projectDirArg), + outputName, + targetType, + projectRoot, + outputBaseDir: outputBaseDir || path.resolve(projectRoot, 'src', targetType), + }; +} + +function copyDirectory(src, dest) { + if (!fs.existsSync(src)) return 0; + fs.mkdirSync(dest, { recursive: true }); + let count = 0; + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === '.next') continue; + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) count += copyDirectory(srcPath, destPath); + else if (entry.isFile()) { + fs.copyFileSync(srcPath, destPath); + count += 1; + } + } + return count; +} + +function ensureIndex(outputDir) { + const indexPath = path.join(outputDir, 'index.tsx'); + if (!fs.existsSync(indexPath)) { + fs.writeFileSync(indexPath, [ + "import React from 'react';", + '', + 'export default function ImportedV0Prototype() {', + ' return
V0 import requires AI conversion.
;', + '}', + '', + ].join('\n'), 'utf8'); + } +} + +function main() { + const parsed = parseArgs(process.argv.slice(2)); + if (!fs.existsSync(path.join(parsed.projectDir, 'app'))) { + throw new Error('这不是一个有效的 V0 项目(缺少 app/ 目录)'); + } + const outputDir = path.join(parsed.outputBaseDir, parsed.outputName); + fs.rmSync(outputDir, { recursive: true, force: true }); + fs.mkdirSync(parsed.outputBaseDir, { recursive: true }); + const fileCount = copyDirectory(parsed.projectDir, outputDir); + ensureIndex(outputDir); + const taskPath = path.join(outputDir, parsed.targetType === 'themes' ? '.v0-theme-tasks.md' : '.v0-tasks.md'); + fs.writeFileSync(taskPath, [ + '# V0 项目转换任务清单', + '', + '> 请先阅读 `rules/v0-project-converter.md` 和 `rules/development-guide.md`,再基于该目录完成原型转换。', + '', + `- 输出目录:\`${normalizeSlashes(path.relative(parsed.projectRoot, outputDir))}/\``, + `- 已复制文件数:${fileCount}`, + '', + ].join('\n'), 'utf8'); + console.log(JSON.stringify({ + success: true, + outputDir, + tasksFile: normalizeSlashes(path.relative(parsed.projectRoot, taskPath)), + })); +} + +try { + main(); +} catch (error) { + console.error(error?.message || String(error)); + process.exit(1); +} diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..912c5b9 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,209 @@ +{ + "version": 1, + "skills": { + "algorithmic-art": { + "source": "anthropics/skills", + "sourceType": "github", + "skillPath": "skills/algorithmic-art/SKILL.md", + "computedHash": "b2ca295de7f9c86c444f1fa21239e22e0eb7013ced1c652169b9402cc96ff744" + }, + "brainstorming": { + "source": "obra/superpowers", + "sourceType": "github", + "skillPath": "skills/brainstorming/SKILL.md", + "computedHash": "225b53fce53d627d3a1e94bfea84060ccdbf11a58a2d5cf11eefef89d598020e" + }, + "brand-guidelines": { + "source": "anthropics/skills", + "sourceType": "github", + "skillPath": "skills/brand-guidelines/SKILL.md", + "computedHash": "e48840db6ea772ceecdb68b4e50f8cc77e2534b7580aeaf4a6fe6ee7bd845d7d" + }, + "canvas-design": { + "source": "anthropics/skills", + "sourceType": "github", + "skillPath": "skills/canvas-design/SKILL.md", + "computedHash": "3fa7ea60170023ad8268bf9fd734ea75dd404279a2ef8ddf5abaa1102ad8e5f4" + }, + "claude-api": { + "source": "anthropics/skills", + "sourceType": "github", + "skillPath": "skills/claude-api/SKILL.md", + "computedHash": "7688fe71c809c208542ddf6b5d222eb5af4ed979ab0a93740bb666c946d484fa" + }, + "design-md": { + "source": "google-labs-code/stitch-skills", + "sourceType": "github", + "skillPath": "plugins/stitch-utilities/skills/design-md/SKILL.md", + "computedHash": "d3a4c7d2f2715a33662c8b06ffc16035d01965a8b4ed81c2958fb22ae94760f8" + }, + "doc-coauthoring": { + "source": "anthropics/skills", + "sourceType": "github", + "skillPath": "skills/doc-coauthoring/SKILL.md", + "computedHash": "b964e4c3ffe13f66f76dd7919073e60c7aec622cd8a8363bb36323188100cf8e" + }, + "docx": { + "source": "anthropics/skills", + "sourceType": "github", + "skillPath": "skills/docx/SKILL.md", + "computedHash": "59c70107f8eae818bdfca7da685a6e4111aa519638b667775b794cb74987ec47" + }, + "enhance-prompt": { + "source": "google-labs-code/stitch-skills", + "sourceType": "github", + "skillPath": "plugins/stitch-utilities/skills/enhance-prompt/SKILL.md", + "computedHash": "71cc23e9ec5fc6361e12453495fa8c683f6cf26f3efaf9b230b0f43e0a32cc96" + }, + "frontend-design": { + "source": "anthropics/skills", + "sourceType": "github", + "skillPath": "skills/frontend-design/SKILL.md", + "computedHash": "4eabc66183767153e404b39d1b839b1c37f2d82d86f0a0d7e880a579d8d62336" + }, + "internal-comms": { + "source": "anthropics/skills", + "sourceType": "github", + "skillPath": "skills/internal-comms/SKILL.md", + "computedHash": "0bdc8867452b7ab9ef6167f6b0db2025ce5fb03773d3f85efcef2dfeb9d4976d" + }, + "mcp-builder": { + "source": "anthropics/skills", + "sourceType": "github", + "skillPath": "skills/mcp-builder/SKILL.md", + "computedHash": "826e46adf81e1372282e205c34e1a2ee876e72148007726e4a2512599d2fef7b" + }, + "pdf": { + "source": "anthropics/skills", + "sourceType": "github", + "skillPath": "skills/pdf/SKILL.md", + "computedHash": "cfbc539377088ca7e44a813b30c306327385bbd973cd7a721e1743f60837dd62" + }, + "pptx": { + "source": "anthropics/skills", + "sourceType": "github", + "skillPath": "skills/pptx/SKILL.md", + "computedHash": "82eaf5daede5a1779aa17ccf06597cb209a0c2bd41d3a2f18bd45e0301a387fb" + }, + "react-vite-dashboard": { + "source": "google-labs-code/stitch-skills", + "sourceType": "github", + "skillPath": "plugins/stitch-build/skills/react-vite-dashboard/SKILL.md", + "computedHash": "0e876b7af074b77a237df2fd539c66f3ebe5fac7a8b64189c58ef0aa3c5f6518" + }, + "remotion": { + "source": "google-labs-code/stitch-skills", + "sourceType": "github", + "skillPath": "plugins/stitch-build/skills/remotion/SKILL.md", + "computedHash": "05fdf6dec07727614f6394d564d5f271f9c46f422ec8135997d15ddcdd214dce" + }, + "shadcn-ui": { + "source": "google-labs-code/stitch-skills", + "sourceType": "github", + "skillPath": "plugins/stitch-build/skills/shadcn-ui/SKILL.md", + "computedHash": "6c1f0ce10a1ceaee5715a1e50a2e3a3159496910364d643bc2a8cab3f2eec4a8" + }, + "skill-creator": { + "source": "anthropics/skills", + "sourceType": "github", + "skillPath": "skills/skill-creator/SKILL.md", + "computedHash": "5ea13a6d9f0d4bb694405d79acd00cadec0d21bb138c4dd10fcf3c500cb835c2" + }, + "slack-gif-creator": { + "source": "anthropics/skills", + "sourceType": "github", + "skillPath": "skills/slack-gif-creator/SKILL.md", + "computedHash": "fab4fcfcaed371071069d61a3ab94dff15d10d36403ff269d147ea986cd98083" + }, + "stitch-loop": { + "source": "google-labs-code/stitch-skills", + "sourceType": "github", + "skillPath": "plugins/stitch-utilities/skills/stitch-loop/SKILL.md", + "computedHash": "e04912a9719b855afa733cc928d30dde0b8a5b91707ca27b98f0efcb8d9310a9" + }, + "stitch::code-to-design": { + "source": "google-labs-code/stitch-skills", + "sourceType": "github", + "skillPath": "plugins/stitch-design/skills/code-to-design/SKILL.md", + "computedHash": "c1ac61e0f455944bec4ffef0ba254d3fbb7a53a6654492e4c54736923dbc2b0c" + }, + "stitch::extract-design-md": { + "source": "google-labs-code/stitch-skills", + "sourceType": "github", + "skillPath": "plugins/stitch-design/skills/extract-design-md/SKILL.md", + "computedHash": "0ddd62f54b939572347733ff8a5537697e2227b916d3c3620b979577864dcff9" + }, + "stitch::extract-static-html": { + "source": "google-labs-code/stitch-skills", + "sourceType": "github", + "skillPath": "plugins/stitch-design/skills/extract-static-html/SKILL.md", + "computedHash": "4b002f266c93a4cb4b6f048e46d5cf42f40085408b10e0b57d13301e618cdac2" + }, + "stitch::generate-design": { + "source": "google-labs-code/stitch-skills", + "sourceType": "github", + "skillPath": "plugins/stitch-design/skills/generate-design/SKILL.md", + "computedHash": "c5cf0685e477166e16283b5c2ee08c5b5022f0712d2a5988fdb475a9de79ad40" + }, + "stitch::manage-design-system": { + "source": "google-labs-code/stitch-skills", + "sourceType": "github", + "skillPath": "plugins/stitch-design/skills/manage-design-system/SKILL.md", + "computedHash": "e55ef1adf10175b9ce3915f0bc7208904805ff14ce954349365dc5aabaa8359b" + }, + "stitch::react-components": { + "source": "google-labs-code/stitch-skills", + "sourceType": "github", + "skillPath": "plugins/stitch-build/skills/react-components/SKILL.md", + "computedHash": "e4f9aaccf174f414274873cbeeee1bb6fa8833ae50457246d670872b0c3e063a" + }, + "stitch::react-native": { + "source": "google-labs-code/stitch-skills", + "sourceType": "github", + "skillPath": "plugins/stitch-build/skills/react-native/SKILL.md", + "computedHash": "f61921ebd602e4fd0dab3594ce34837c48744c4ee0f2be8671bef83c99767586" + }, + "stitch::upload-to-stitch": { + "source": "google-labs-code/stitch-skills", + "sourceType": "github", + "skillPath": "plugins/stitch-design/skills/upload-to-stitch/SKILL.md", + "computedHash": "bd1970c14522f9146fda05c261072a5b59e83bf5ce45e7d45a254939442022d6" + }, + "taste-design": { + "source": "google-labs-code/stitch-skills", + "sourceType": "github", + "skillPath": "plugins/stitch-utilities/skills/taste-design/SKILL.md", + "computedHash": "1891625787d6f0f3bc45d4a9437c187277af66c219e3ec799b1b4e67822b6146" + }, + "template-skill": { + "source": "anthropics/skills", + "sourceType": "github", + "skillPath": "template/SKILL.md", + "computedHash": "38c88f722b574a922311d334a70c2008a2a5b896f06eaa75516d716e5725b26f" + }, + "theme-factory": { + "source": "anthropics/skills", + "sourceType": "github", + "skillPath": "skills/theme-factory/SKILL.md", + "computedHash": "598ddfa9784ae2a8c55be5dbb047b98564d875dea6f4669168a87ae1ea1a4ed3" + }, + "web-artifacts-builder": { + "source": "anthropics/skills", + "sourceType": "github", + "skillPath": "skills/web-artifacts-builder/SKILL.md", + "computedHash": "d52d422ead3fa26859a6af129609841a8536b230a2d649f86e61771d063e05e2" + }, + "webapp-testing": { + "source": "anthropics/skills", + "sourceType": "github", + "skillPath": "skills/webapp-testing/SKILL.md", + "computedHash": "ad5b1fc52807e9afa4635e59218a026b164dc58b6c3f41b0f7c644dcd6ccf572" + }, + "xlsx": { + "source": "anthropics/skills", + "sourceType": "github", + "skillPath": "skills/xlsx/SKILL.md", + "computedHash": "f5945e6e76dc7c178375e5cdf3140959b30cbcd4570ec3dc62296812259fc38d" + } + } +} diff --git a/src/common/DesignMdBatchShowcase/base.css b/src/common/DesignMdBatchShowcase/base.css new file mode 100644 index 0000000..2f59208 --- /dev/null +++ b/src/common/DesignMdBatchShowcase/base.css @@ -0,0 +1,964 @@ +.dmb-page { + min-height: 100vh; + background: var(--dmb-bg, #ffffff); + color: var(--dmb-ink, #111318); + font-family: var(--dmb-font-body, var(--dmb-font-sans)); +} + +.dmb-layout { + width: min(1320px, calc(100vw - 48px)); + margin: 0 auto; + padding: 42px 0 76px; +} + +.dmb-sheet { + background: transparent; + border: 0; + border-radius: 0; + padding: 0; +} + +.dmb-sheet-head { + width: 100%; + min-height: 68px; + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; + padding-bottom: 22px; + border-bottom: 1px solid color-mix(in srgb, var(--dmb-border, #e1e2e7) 72%, transparent); +} + +.dmb-sheet-head-main { + min-width: 0; + max-width: 920px; +} + +.dmb-sheet-actions { + flex: 0 0 auto; + align-self: flex-end; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 10px; + max-width: min(100%, 640px); +} + +.dmb-sheet-head p { + margin: 0 0 6px; + color: var(--dmb-ink-subtle, #686c76); + font-size: 12px; + font-weight: 700; +} + +.dmb-sheet-head h1 { + margin: 0; + color: var(--dmb-ink, #121319); + font-family: var(--dmb-font-display, var(--dmb-font-body, var(--dmb-font-sans))); + font-size: 54px; + line-height: 1.08; + font-weight: 800; + letter-spacing: 0; +} + +.dmb-sheet-links { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin: 0; + position: relative; + isolation: isolate; +} + +.dmb-sheet-links a { + position: relative; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: 36px; + block-size: 36px; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--dmb-ink-subtle, #64748b); + padding: 0; + box-shadow: none; + text-decoration: none; + line-height: 1; + transition: transform 160ms ease, color 160ms ease; +} + +.dmb-sheet-links a:hover { + color: var(--dmb-ink, #111318); + text-decoration: none; + transform: translateY(-1px); +} + +.dmb-sheet-links a:focus-visible { + color: var(--dmb-ink, #111318); + text-decoration: none; + transform: translateY(-1px); + outline: 2px solid currentColor; + outline-offset: 3px; +} + +.dmb-sheet-links svg { + width: 16px; + height: 16px; + flex: 0 0 auto; + stroke: currentColor; + fill: none; +} + +.dmb-sheet-links a::after { + content: attr(data-label); + position: absolute; + left: 50%; + bottom: calc(100% + 6px); + z-index: 4; + min-width: max-content; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 8px; + background: rgba(15, 23, 42, 0.86); + color: #ffffff; + padding: 6px 8px; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 11px; + font-weight: 700; + line-height: 1; + letter-spacing: 0; + opacity: 0; + pointer-events: none; + transform: translate(-50%, 4px); + transition: opacity 140ms ease, transform 140ms ease; +} + +.dmb-sheet-links a:hover::after, +.dmb-sheet-links a:focus-visible::after { + opacity: 1; + transform: translate(-50%, 0); +} + +.dmb-tabs { + display: inline-flex; + align-items: center; + gap: 4px; + margin: 0; + padding: 4px; + border: 1px solid var(--dmb-tab-border, #e1e2e7); + border-radius: var(--dmb-radius-card, 0); + background: var(--dmb-tab-bg, #f7f5ef); +} + +.dmb-tabs button { + min-height: 32px; + border: 1px solid transparent; + border-radius: var(--dmb-radius-control, 0); + background: transparent; + color: var(--dmb-tab-color, #5f6370); + padding: 0 12px; + font: inherit; + font-family: var(--dmb-font-body, var(--dmb-font-sans)); + font-size: 12px; + font-weight: 800; + cursor: pointer; +} + +.dmb-tabs button[aria-selected="true"] { + border-color: var(--dmb-tab-active-border, color-mix(in srgb, var(--dmb-accent) 38%, #d9dbe2)); + background: var(--dmb-tab-active-bg, #ffffff); + color: var(--dmb-tab-active-color, #111318); +} + +.dmb-tab-panel { + min-width: 0; +} + +.dmb-preview { + width: 100%; + height: 620px; + border: 0; + border-radius: var(--dmb-radius-preview, 0); + background: color-mix(in srgb, var(--dmb-accent) 4%, #f6f7fa); + padding: 0; + overflow: hidden; + display: block; + position: relative; + cursor: zoom-in; +} + +.dmb-preview::before { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + border: 1px solid color-mix(in srgb, var(--dmb-border, #dfe1e7) 80%, transparent); + border-radius: inherit; +} + +.dmb-preview-canvas { + width: 100%; + height: 100%; + display: block; + overflow: hidden; + border-radius: var(--dmb-radius-preview, 0); +} + +.dmb-preview img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center top; + display: block; + border-radius: 0; +} + +.dmb-preview iframe { + width: 100%; + height: 100%; + border: 0; + display: block; + pointer-events: none; + background: #ffffff; +} + +.dmb-preview-label { + position: absolute; + z-index: 2; + right: 12px; + bottom: 12px; + border: 1px solid rgba(255, 255, 255, 0.72); + border-radius: var(--dmb-radius-pill, 0); + background: rgba(17, 19, 24, 0.7); + color: #ffffff; + padding: 5px 9px; + font-family: var(--dmb-font-body, var(--dmb-font-sans)); + font-size: 11px; + font-weight: 700; +} + +.dmb-preview-empty { + display: grid; + place-items: center; + cursor: default; + color: #8a8c95; +} + +.dmb-preview-empty strong, +.dmb-preview-empty span { + display: block; + position: static; + border: 0; + background: transparent; + color: inherit; + padding: 0; +} + +.dmb-preview-empty strong { + color: #1b1d24; + font-size: 30px; +} + +.dmb-description { + max-width: 1040px; + margin: 0; +} + +.dmb-description p { + margin: 0; + color: var(--dmb-ink-muted, #3f424b); + font-family: var(--dmb-font-body, var(--dmb-font-sans)); + font-size: 15px; + line-height: 1.65; +} + +.dmb-description p + p { + margin-top: 4px; + color: var(--dmb-ink-subtle, #6c707a); + font-size: 13px; +} + +.dmb-tags { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 10px; +} + +.dmb-overview-meta { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 18px 28px; + align-items: start; + margin-top: 22px; + padding: 18px 0 0; + border-top: 1px solid color-mix(in srgb, var(--dmb-border, #e1e2e7) 64%, transparent); +} + +.dmb-overview-meta .dmb-tags { + justify-content: flex-end; + margin-top: 2px; + max-width: 420px; +} + +.dmb-tags span, +.dmb-muted { + border: 1px solid #e2e3e8; + border-radius: var(--dmb-radius-pill, 0); + background: #f4f1ea; + color: #545964; + padding: 5px 8px; + font-size: 11px; + line-height: 1; +} + +.dmb-token-card, +.dmb-block { + margin-top: 30px; +} + +.dmb-token-card { + border: 1px solid #e1e2e7; + border-radius: var(--dmb-radius-card, 0); + background: color-mix(in srgb, var(--dmb-accent) 9%, #ffffff); + padding: 12px; + display: grid; + grid-template-columns: 190px 1fr; + gap: 10px; +} + +.dmb-token-rail, +.dmb-token-grid { + display: grid; + gap: 8px; +} + +.dmb-token-chip { + min-height: 58px; + border-radius: var(--dmb-radius-control, 0); + padding: 10px; + display: flex; + flex-direction: column; + justify-content: space-between; + color: #ffffff; + overflow: hidden; +} + +.dmb-token-chip strong { + font-size: 11px; +} + +.dmb-token-chip code { + font-family: var(--dmb-font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); + font-size: 10px; + color: currentColor; +} + +.dmb-token-grid { + grid-template-columns: minmax(0, 1.05fr) minmax(164px, 0.82fr) minmax(150px, 0.76fr) minmax(0, 1fr) minmax(0, 1fr); +} + +.dmb-token-grid > div { + min-height: 90px; + border-radius: var(--dmb-radius-control, 0); + background: color-mix(in srgb, var(--dmb-accent) 7%, #ffffff); + padding: 14px; + overflow: hidden; +} + +.dmb-token-grid small { + display: block; + margin-bottom: 8px; + color: #8a8c95; + font-size: 11px; +} + +.dmb-token-grid strong { + font-family: var(--dmb-font-display, var(--dmb-font-body, var(--dmb-font-sans))); + font-size: 48px; + line-height: 0.9; +} + +.dmb-token-grid button, +.dmb-component-strip button { + min-height: 34px; + border: 1px solid var(--dmb-accent); + border-radius: var(--dmb-radius-control, 0); + background: var(--dmb-accent); + color: var(--dmb-accent-contrast); + padding: 4px 12px; + font-size: 11px; + line-height: 1.1; + font-weight: 800; + margin: 0 6px 6px 0; +} + +.dmb-token-buttons { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; +} + +.dmb-token-buttons button { + width: 118px; + margin: 0; +} + +.dmb-token-grid button span { + font-size: 9px; + font-weight: 700; + opacity: 0.78; +} + +.dmb-token-grid .dmb-text-button { + background: transparent; + color: var(--dmb-link); +} + +.dmb-outline-button { + background: #ffffff !important; + color: var(--dmb-link) !important; + border-color: #d9d9df !important; +} + +.dmb-token-grid input { + width: 100%; + height: 34px; + border: 1px solid #ededf0; + border-radius: var(--dmb-radius-control, 0); + background: #ffffff; + padding: 0 12px; + font: inherit; + font-family: var(--dmb-font-body, var(--dmb-font-sans)); +} + +.dmb-lines { + display: grid; + align-content: center; + gap: 7px; +} + +.dmb-lines span { + display: block; + height: 3px; + background: var(--dmb-accent); +} + +.dmb-lines span:nth-child(2) { + width: 84%; +} + +.dmb-lines span:nth-child(3) { + width: 68%; +} + +.dmb-lines span:nth-child(4) { + width: 50%; +} + +.dmb-block h2 { + margin: 0 0 14px; + color: var(--dmb-ink, #111318); + font-size: 12px; + line-height: 1.2; + text-transform: uppercase; + letter-spacing: 0; +} + +.dmb-block h2 span { + margin-left: 8px; + color: var(--dmb-ink-subtle, var(--dmb-link)); + font-weight: 600; + text-transform: none; +} + +.dmb-palette { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 8px; +} + +.dmb-palette article { + min-height: 122px; + border-radius: var(--dmb-radius-card, 0); + padding: 12px; + display: flex; + flex-direction: column; + justify-content: space-between; + overflow: hidden; +} + +.dmb-palette strong, +.dmb-palette code, +.dmb-palette p { + display: block; + margin: 0; +} + +.dmb-palette strong { + font-size: 12px; + line-height: 1.2; +} + +.dmb-palette code { + font-family: var(--dmb-font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); + font-size: 11px; +} + +.dmb-palette p { + font-size: 10px; + line-height: 1.35; + opacity: 0.84; +} + +.dmb-type-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; +} + +.dmb-type-list article, +.dmb-component-strip, +.dmb-component-grid, +.dmb-spacing-scale { + border: 1px solid #ededf0; + border-radius: var(--dmb-radius-card, 0); + background: #f0f0f1; +} + +.dmb-type-list article { + min-height: 58px; + padding: 14px 16px; + overflow: hidden; +} + +.dmb-type-list div { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 6px; +} + +.dmb-type-list strong { + color: #111318; + font-size: 11px; +} + +.dmb-type-list span { + color: #7d808a; + font-size: 11px; +} + +.dmb-type-list p { + margin: 0; + color: #111318; + font-size: var(--dmb-type-size, 18px); + line-height: 1.04; + font-weight: var(--dmb-type-weight, 600); +} + +.dmb-type-wide { + grid-column: 1 / -1; + min-height: 224px; +} + +.dmb-type-wide .dmb-type-sample { + width: 100%; +} + +.dmb-type-list .dmb-type-sample-zh, +.dmb-type-list .dmb-type-sample-en { + display: block; + color: inherit; + font-size: inherit; + font-weight: inherit; + font-style: normal; + overflow-wrap: anywhere; +} + +.dmb-type-list .dmb-type-sample-en { + margin-top: 0.18em; + color: color-mix(in srgb, var(--dmb-link) 72%, #111318); + font-size: inherit; + font-weight: inherit; +} + +.dmb-type-wide .dmb-type-sample-en { + margin-top: 0.2em; + white-space: nowrap; + overflow-wrap: normal; + word-break: normal; +} + +.dmb-type-sample-fit { + max-width: 100%; + overflow: visible; +} + +.dmb-type-list .dmb-type-sample-fit-inner { + display: inline-block; + color: inherit; + font: inherit; + line-height: inherit; + max-width: none; + white-space: inherit; + transform: scale(var(--dmb-type-fit-scale, 1)); + transform-origin: left top; +} + +.dmb-type-display p { + font-family: var(--dmb-font-display, var(--dmb-font-body, var(--dmb-font-sans))); +} + +.dmb-type-body p { + font-family: var(--dmb-font-body, var(--dmb-font-sans)); + line-height: 1.38; +} + +.dmb-type-mono p { + font-family: var(--dmb-font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); + line-height: 1.35; +} + +.dmb-spacing-scale { + height: 116px; + padding: 24px; + display: flex; + align-items: center; + gap: 12px; + overflow-x: auto; +} + +.dmb-spacing-scale div { + display: grid; + gap: 8px; + justify-items: center; + color: #777a84; + font-size: 11px; +} + +.dmb-spacing-scale i { + display: block; + width: 38px; + height: 18px; + border-left: 1px solid #999ca6; + border-right: 1px solid #999ca6; + border-bottom: 1px solid #999ca6; +} + +.dmb-radius-grid, +.dmb-shadow-grid, +.dmb-border-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.dmb-shadow-grid, +.dmb-border-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.dmb-radius-grid article, +.dmb-shadow-grid article, +.dmb-border-grid article { + min-height: 154px; + border: 1px solid #ededf0; + border-radius: var(--dmb-radius-card, 0); + background: #ffffff; + padding: 14px; + overflow: hidden; +} + +.dmb-radius-sample { + height: 58px; + border-radius: var(--dmb-radius-demo); + background: var(--dmb-surface); + border: 1px solid var(--dmb-border); + margin-bottom: 12px; +} + +.dmb-radius-grid strong, +.dmb-shadow-grid strong, +.dmb-border-grid strong { + display: block; + color: #151720; + font-size: 12px; + line-height: 1.25; +} + +.dmb-radius-grid code { + display: block; + margin-top: 7px; + color: var(--dmb-link); + font-family: var(--dmb-font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); + font-size: 11px; +} + +.dmb-radius-grid p, +.dmb-shadow-grid p, +.dmb-border-grid p { + margin: 8px 0 0; + color: #676b76; + font-size: 11px; + line-height: 1.42; +} + +.dmb-shadow-grid article { + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +.dmb-border-sample { + display: block; + height: 58px; + margin: 14px 0 12px; + border: 1px solid #dfe1e7; + border-radius: 0; + background: var(--dmb-border-sample-bg, var(--dmb-surface, var(--dmb-bg, #ffffff))); +} + +.dmb-component-strip { + padding: 16px; + margin-bottom: 10px; +} + +.dmb-component-strip .dmb-button-hover { + filter: brightness(0.92); +} + +.dmb-component-grid { + padding: 14px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.dmb-component-grid article { + min-height: 122px; + border-radius: var(--dmb-radius-card, 0); + background: #ffffff; + border: 1px solid #e5e5e9; + padding: 14px; +} + +.dmb-component-grid small { + color: var(--dmb-link); + font-size: 11px; + font-weight: 800; +} + +.dmb-component-grid h3 { + margin: 12px 0 8px; + color: #151720; + font-size: 16px; +} + +.dmb-component-grid p { + margin: 0; + color: #666a75; + font-size: 12px; + line-height: 1.55; +} + +.dmb-dos-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.dmb-dos-grid article { + border-radius: var(--dmb-radius-card, 0); + padding: 16px; + min-height: 142px; +} + +.dmb-do { + border: 1px solid #bcebd8; + background: #effbf6; + color: #0b6b43; +} + +.dmb-dont { + border: 1px solid #f4c7c7; + background: #fff1f1; + color: #b32626; +} + +.dmb-dos-grid strong { + display: block; + margin-bottom: 10px; + font-size: 13px; +} + +.dmb-dos-grid p { + margin: 0 0 9px; + color: inherit; + font-size: 12px; + line-height: 1.45; +} + +.dmb-lightbox { + position: fixed; + inset: 0; + z-index: 80; + background: rgba(8, 10, 14, 0.88); + padding: 54px 24px 28px; + display: flex; + justify-content: center; + align-items: flex-start; + overflow: hidden; +} + +.dmb-lightbox > button { + position: absolute; + top: 18px; + left: 50%; + transform: translateX(-50%); + height: 34px; + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: var(--dmb-radius-control, 0); + background: rgba(255, 255, 255, 0.12); + color: #ffffff; + padding: 0 12px; + font: inherit; + font-family: var(--dmb-font-body, var(--dmb-font-sans)); + font-size: 12px; + font-weight: 800; +} + +.dmb-lightbox-scroll { + width: min(1040px, calc(100vw - 48px)); + max-width: calc(100vw - 48px); + height: auto; + max-height: calc(100vh - 86px); + overflow-y: auto; + overflow-x: hidden; + border-radius: var(--dmb-radius-preview, 0); + background: transparent; + padding: 0; +} + +.dmb-lightbox img { + width: 100%; + height: auto; + max-height: none; + display: block; + margin: 0; + border-radius: var(--dmb-radius-preview, 0); +} + +.dmb-lightbox iframe { + width: 100%; + height: calc(100vh - 86px); + min-height: calc(100vh - 86px); + border: 0; + display: block; + background: #ffffff; + border-radius: var(--dmb-radius-preview, 0); +} + +@media (max-width: 980px) { + .dmb-layout { + width: min(100% - 32px, 1320px); + } + + .dmb-token-grid, + .dmb-type-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .dmb-radius-grid, + .dmb-shadow-grid, + .dmb-border-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .dmb-layout { + width: min(100% - 20px, 1320px); + padding: 20px 0 44px; + } + + .dmb-sheet { + padding: 0; + border-radius: 0; + } + + .dmb-sheet-head { + align-items: flex-start; + flex-direction: column; + } + + .dmb-sheet-actions, + .dmb-tabs { + width: 100%; + } + + .dmb-sheet-actions { + justify-content: flex-start; + } + + .dmb-sheet-links { + width: auto; + } + + .dmb-tabs button { + flex: 1 1 0; + } + + .dmb-preview { + height: 360px; + } + + .dmb-overview-meta { + grid-template-columns: 1fr; + } + + .dmb-overview-meta .dmb-tags { + justify-content: flex-start; + max-width: none; + } + + .dmb-lightbox { + padding: 52px 12px 16px; + } + + .dmb-lightbox-scroll { + width: min(720px, calc(100vw - 24px)); + max-width: calc(100vw - 24px); + max-height: calc(100vh - 68px); + } + + .dmb-token-card { + grid-template-columns: 1fr; + } + + .dmb-token-grid, + .dmb-type-list, + .dmb-palette, + .dmb-radius-grid, + .dmb-shadow-grid, + .dmb-border-grid, + .dmb-component-grid, + .dmb-dos-grid { + grid-template-columns: 1fr; + } + + .dmb-sheet-head h1 { + font-size: 32px; + } + + .dmb-sheet-head span { + font-size: 15px; + } +} diff --git a/src/common/DesignMdBatchShowcase/headerLinks.ts b/src/common/DesignMdBatchShowcase/headerLinks.ts new file mode 100644 index 0000000..c74b71b --- /dev/null +++ b/src/common/DesignMdBatchShowcase/headerLinks.ts @@ -0,0 +1,28 @@ +export type BatchShowcaseSourceLinks = { + sourceName?: string; + originalDetailUrl?: string; + websiteUrl?: string; +}; + +export type HeaderSourceLink = { + ariaLabel: string; + href: string; + kind: 'website' | 'source'; + label: string; + title: string; +}; + +function isExternalUrl(value?: string) { + return Boolean(value && /^https?:\/\//i.test(value.trim())); +} + +export function getHeaderLinks(source?: BatchShowcaseSourceLinks): HeaderSourceLink[] { + return [ + source?.websiteUrl && isExternalUrl(source.websiteUrl) + ? { ariaLabel: '打开品牌官网', href: source.websiteUrl, kind: 'website', label: '官网', title: '品牌官网' } + : null, + source?.originalDetailUrl && isExternalUrl(source.originalDetailUrl) + ? { ariaLabel: '打开采集来源', href: source.originalDetailUrl, kind: 'source', label: '来源', title: '主题来源' } + : null, + ].filter((item): item is HeaderSourceLink => Boolean(item)); +} diff --git a/src/common/DesignMdBatchShowcase/index.tsx b/src/common/DesignMdBatchShowcase/index.tsx new file mode 100644 index 0000000..9dab400 --- /dev/null +++ b/src/common/DesignMdBatchShowcase/index.tsx @@ -0,0 +1,802 @@ +import './base.css'; +import { FileSearch, Globe2 } from 'lucide-react'; +import React, { useEffect, useRef, useState } from 'react'; + +import { getHeaderLinks, type BatchShowcaseSourceLinks } from './headerLinks'; + +type TypographyFontRole = 'display' | 'body' | 'mono'; + +export type BatchPreviewImage = { + type: string; + url: string; +}; + +export type BatchPaletteSwatch = { + color: string; + labelZh: string; + labelEn: string; + textColor: string; +}; + +export type BatchStyleSystemItem = { + label: string; + value: string; + description?: string; + cssValue?: string; +}; + +export type BatchShowcaseConfig = { + brand: string; + brandAlias?: string; + description: string; + descriptionEn?: string; + source?: BatchShowcaseSourceLinks; + variant: 'saas-devtool' | 'dashboard' | 'consumer-commerce' | 'editorial-agency' | 'dark-experimental'; + distributionTags: string[]; + fontStylesheets?: string[]; + palette: Array; + radius?: { + control?: string; + card?: string; + preview?: string; + pill?: string; + source?: string; + }; + spacing?: Record; + shadows?: BatchStyleSystemItem[]; + borders?: BatchStyleSystemItem[]; + typography: string[]; + previewImages: BatchPreviewImage[]; + panels: { + title: string; + eyebrow: string; + body: string; + }[]; + usageGuidance?: { + do: string[]; + dont: string[]; + }; +}; + +export type BatchShowcaseTab = { + id: string; + label: string; + content: React.ReactNode; +}; + +export type DesignMdBatchShowcaseProps = { + config: BatchShowcaseConfig; + tabs?: BatchShowcaseTab[]; + className?: string; +}; + +const variantLabels: Record = { + 'saas-devtool': 'SaaS 开发工具 - SaaS / Devtool', + dashboard: '数据仪表盘 - Dashboard', + 'consumer-commerce': '消费与商业 - Consumer / Commerce', + 'editorial-agency': '编辑与机构 - Editorial / Agency', + 'dark-experimental': '暗色实验 - Dark / Experimental', +}; + +const NON_SELECTION_TAGS = new Set([ + 'DESIGN.md', + '设计 Token', + '品牌指南', + 'GitHub Raw', + 'Tailwind v4', + 'CSS 变量', + '图片资产', + '浏览器复制', + '目录来源', +]); + +const typographyRows = [ + { + name: '展示标题 - Display', + fontRole: 'display', + layout: 'wide', + size: '72px', + weight: 'bold', + cssWeight: 800, + zh: '主题字形展示', + en: 'Design System Display', + }, + { + name: '页面标题 - Heading', + fontRole: 'display', + layout: 'wide', + size: '48px', + weight: 'bold', + cssWeight: 800, + zh: '标题层级', + en: 'Product Experience', + }, + { + name: '分区标题 - Section Heading', + fontRole: 'display', + layout: 'compact', + size: '28px', + weight: 'bold', + cssWeight: 800, + zh: '视觉系统', + en: 'Visual Language', + }, + { + name: '副标题 - Subhead', + fontRole: 'body', + layout: 'compact', + size: '20px', + weight: 'semibold', + cssWeight: 600, + zh: '清晰的信息节奏', + en: 'A clear rhythm for product stories.', + }, + { + name: '正文 - Body', + fontRole: 'body', + layout: 'compact', + size: '15px', + weight: 'regular', + cssWeight: 400, + zh: '每一种字体都让主题拥有不同的声音。', + en: 'Typography gives every theme its own voice.', + }, + { + name: '小字 - Small', + fontRole: 'body', + layout: 'compact', + size: '12px', + weight: 'regular', + cssWeight: 400, + zh: '辅助说明与状态信息', + en: 'Helper text and interface states', + }, + { + name: '注释 - Caption', + fontRole: 'mono', + layout: 'compact', + size: '11px', + weight: 'regular', + cssWeight: 400, + zh: '来源注释与细节标记', + en: 'Source notes and fine details', + }, +] satisfies Array<{ + name: string; + fontRole: TypographyFontRole; + layout: 'wide' | 'compact'; + size: string; + weight: string; + cssWeight: number; + zh: string; + en: string; +}>; + +function unique(items: string[]) { + return Array.from(new Set(items.filter(Boolean))); +} + +function themeSelectionTags(tags: string[]) { + return unique(tags.map(tag => tag.trim()).filter(tag => !NON_SELECTION_TAGS.has(tag))); +} + +function themeSelectionDescription(description: string) { + const text = Array.from(NON_SELECTION_TAGS).reduce( + (current, tag) => current.replace(new RegExp(`、?${tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'g'), ''), + description, + ); + return text + .replace(/围绕、/g, '围绕') + .replace(/围绕组织页面/g, '围绕品牌视觉、产品场景组织页面') + .replace(/、、+/g, '、'); +} + +function contrastFor(color: string) { + const value = color.replace('#', '').slice(0, 6); + if (value.length !== 6) return '#171717'; + const r = parseInt(value.slice(0, 2), 16); + const g = parseInt(value.slice(2, 4), 16); + const b = parseInt(value.slice(4, 6), 16); + return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? '#171717' : '#ffffff'; +} + +function normalizePalette(palette: Array): BatchPaletteSwatch[] { + const labels = [ + ['主色', 'Primary'], + ['主色悬停', 'Primary Hover'], + ['辅助色', 'Secondary'], + ['强调色', 'Accent'], + ['背景色', 'Background'], + ['表面色', 'Surface'], + ['文本色', 'Text'], + ['边框色', 'Border'], + ]; + return palette.slice(0, 12).map((item, index) => { + if (typeof item !== 'string') return item; + const label = labels[index] || [`颜色 ${index + 1}`, `Color ${index + 1}`]; + return { + color: item, + labelZh: label[0], + labelEn: label[1], + textColor: contrastFor(item), + }; + }); +} + +function uniquePaletteByColor(palette: BatchPaletteSwatch[]): BatchPaletteSwatch[] { + const seen = new Set(); + return palette.filter(swatch => { + const key = swatch.color.trim().toLowerCase(); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function useThemeFontStylesheets(urls: string[] = []) { + useEffect(() => { + if (typeof document === 'undefined' || urls.length === 0) return; + + const links = urls.map(url => { + const existing = document.querySelector(`link[data-dmb-font="${url}"]`); + if (existing) return existing; + + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = url; + link.dataset.dmbFont = url; + document.head.appendChild(link); + return link; + }); + + return () => { + links.forEach(link => { + if (document.querySelectorAll(`link[data-dmb-font="${link.dataset.dmbFont}"]`).length === 1) { + link.remove(); + } + }); + }; + }, [urls.join('|')]); +} + +function FittedSampleText({ + children, + className, + lang, +}: { + children: string; + className: string; + lang: string; +}) { + const containerRef = useRef(null); + const textRef = useRef(null); + const [fitScale, setFitScale] = useState(1); + + useEffect(() => { + const container = containerRef.current; + const text = textRef.current; + if (!container || !text) return; + + let active = true; + let frame = 0; + const updateFit = () => { + if (!active) return; + window.cancelAnimationFrame(frame); + frame = window.requestAnimationFrame(() => { + if (!active) return; + const availableWidth = container.clientWidth; + const textWidth = text.scrollWidth; + const nextScale = availableWidth > 0 && textWidth > 0 + ? Math.min(1, availableWidth / textWidth) + : 1; + setFitScale(current => (Math.abs(current - nextScale) < 0.005 ? current : nextScale)); + }); + }; + + updateFit(); + + let resizeObserver: ResizeObserver | null = null; + if (typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(updateFit); + resizeObserver.observe(container); + resizeObserver.observe(text); + } + + document.fonts?.ready.then(updateFit).catch(() => {}); + window.addEventListener('resize', updateFit); + + return () => { + active = false; + window.cancelAnimationFrame(frame); + resizeObserver?.disconnect(); + window.removeEventListener('resize', updateFit); + }; + }, [children]); + + return ( + + {children} + + ); +} + +function TagList({ tags }: { tags: string[] }) { + if (tags.length === 0) return 暂无标签; + return ( +
+ {tags.map(tag => {tag})} +
+ ); +} + +function PreviewFigure({ config, onOpen }: { config: BatchShowcaseConfig; onOpen: (url: string) => void }) { + const primaryImage = config.previewImages[0]; + const imageLabel = config.brandAlias || config.brand; + + if (!primaryImage) { + return ( + + ); + } + + if (primaryImage.type === 'source-preview-html') { + return ( +