diff --git a/.architecture/ARCHITECTURE.md b/.architecture/ARCHITECTURE.md index 695924d..6aec86a 100644 --- a/.architecture/ARCHITECTURE.md +++ b/.architecture/ARCHITECTURE.md @@ -67,7 +67,7 @@ synthtabs/ ├── default-pages/ Starter HTML templates (copied on init) ├── default-scripts/ OS-specific shell script templates ├── default-themes/ Theme CSS + JSON (Nebula Dusk/Dawn) -├── page-scripts/ Versioned page scripts (page-v2.js, helpers-v2.js) +├── static-files/ Versioned static files (page-v2.js, helpers-v2.js) ├── required-pages/ System pages (builder, settings, pages, scripts, apis) ├── service-connectors/ 28+ connector JSON definitions ├── migration-rules/ Markdown rules for page upgrades @@ -75,7 +75,7 @@ synthtabs/ ├── teams-default-pages/ [TEAMS] Custom page templates ├── teams-default-scripts/ [TEAMS] Custom shell scripts ├── teams-default-themes/ [TEAMS] Custom themes -├── teams-page-scripts/ [TEAMS] Custom versioned page scripts +├── teams-static-files/ [TEAMS] Custom versioned static files ├── teams-required-pages/ [TEAMS] Custom system pages ├── teams-service-connectors/ [TEAMS] Custom connectors └── .synthos/ USER DATA (created at runtime, git-ignored) @@ -127,13 +127,15 @@ with an injected ` + + + + diff --git a/Style-Guide.html b/Style-Guide.html new file mode 100644 index 0000000..bd047ab --- /dev/null +++ b/Style-Guide.html @@ -0,0 +1,1199 @@ + + + + + + FluentLM — Stylable Properties + + + + + + + + + + + + + + + + + + + + +
+
+
FluentLM
+
Design System
+
+
+ + + + + + + + + + + +
+ + 1 / 22 + +
+
+
+ + +
+ + +
+
+

FluentLM

+

A complete design token and component system for building Fluent-styled web applications. Every property shown here is fully stylable.

+
to navigate · click a theme above to switch
+
+
+ + +
+

Theme Accent Ramp

+

The 9-stop accent ramp drives all interactive elements — buttons, links, focus rings, selections, and icons. Override these tokens per-theme to rebrand the entire system.

+
+

Semantic Usage

+ + + + + + + + + +
TokenUsed By
--themePrimaryPrimary buttons, active tabs, active nav borders, filter pills, links
--themeSecondaryInput focus borders, input icons, menu icons, hover accents
--themeDarkAltPrimary button hover, input checked hover
--themeDarkPrimary button pressed states
--themeTertiarySubtle accents, icon tints
--themeLightLight accent backgrounds, selection highlights
--themeLighterLightest accent wash, hover tints
+
+ + +
+

Neutral Palette

+

A 15-stop neutral ramp from black to white. These feed into all semantic surface, text, and border tokens.

+
+
+ + +
+

Accent Colors

+

Named accent colors for status indicators, data visualization, and brand expression.

+
+
+ + +
+

Typography

+

A complete type scale with matching line heights. Use the .flm-text--* utility classes or reference tokens directly.

+
+
+

Type Scale

+
+
+
+

Font Weights

+
+ Light (100) + Semilight (300) + Regular (400) + Semibold (600) + Bold (700) +
+

Monospace

+
+ const items = await api.fetchItems(query);
+ console.log(items.length); +
+

Text Utilities

+
+ flm-text--secondary + flm-text--disabled + flm-text--error + flm-text--success +
+
+
+
+ + +
+

Spacing, Radius & Elevation

+

Standardized spacing, corner radius, and shadow tokens for consistent depth and layout.

+
+
+

Spacing

+
+
+
+

Border Radius

+
+
+
+ roundedCorner2 (2px) +
+
+
+ roundedCorner4 (4px) +
+
+
+ roundedCorner6 (6px) +
+
+

Motion

+ + + + + +
--duration1100ms
--duration2200ms
--duration3300ms
--duration4400ms
+
+
+

Elevation

+
+
elevation4
+
elevation8
+
elevation16
+
elevation64
+
+
+
+
+ + +
+

Buttons

+

All button variants, states, and compositions. 16 button tokens control every visual state.

+
+
+

Variants

+
+ + + + +
+
Disabled
+
+ + + +
+
With Icons
+
+ + + +
+
+
+

Compound Button

+ +

Split Button

+
+ +
+

Button Tokens

+ + + + + + +
--buttonBackground
--buttonBackgroundHovered
--primaryButtonBackground
--primaryButtonBackgroundHovered
--buttonBorder
+
+
+
+ + +
+

Text Fields

+

Input fields with labels, required states, error states, prefixes/suffixes, and textarea support.

+
+
+

Standard

+
+
+ + +
+
+ + +
+
+ + + This field contains invalid characters. +
+
+ + +
+
+
+
+

Variants

+
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+

Input Tokens

+ + + + + +
--inputBackground
--inputBorder
--inputFocusBorderAlt
--inputText
+
+
+
+ + +
+

Dropdowns, ComboBox & Search

+

Selection controls with filtering, multi-select, and search capabilities.

+
+
+

Dropdown

+
+
+ +
+
+
+ +
+
+

ComboBox

+
+
+
+ + +
+
+
+
+
+
+

SearchBox

+
+ + +
+

TagPicker

+
+
+
+ design + frontend + +
+
+
backend
+
testing
+
docs
+
+
+
+

Swatch Color Picker

+
+ + + + + + +
+
+
+
+ + +
+

Selection Controls

+

Checkboxes, radio groups, toggles, sliders, spin buttons, and rating stars.

+
+
+

Checkbox

+
+ + + +
+

ChoiceGroup (Radio)

+
+ + + +
+
+
+

Toggle

+
+ + + +
+

Slider

+
+
+ +
+
Confidence65
+
+

SpinButton

+
+
+ + + +
+
+

Rating

+
+ + + + + +
+
+
+
+ + +
+

Date & Time Pickers

+

Calendar and time selection controls with min/max constraints and formatting options.

+
+
+

DatePicker

+
+
+
+ + +
+
+
+

DatePicker with Error

+
+
+
+ + +
+ Invalid date format. +
+
+
+
+

TimePicker

+
+
+
+ + +
+
+
+

TimePicker Disabled

+
+
+
+ + +
+
+
+
+
+
+ + +
+

CommandBar & Breadcrumb

+

Application-level navigation and action chrome.

+
+
+

CommandBar

+
+
+ + + + + +
+
+ +
+
+
+
+

Breadcrumb

+
+
Default
+
    +
  1. Home
  2. +
  3. Projects
  4. +
  5. Alpha
  6. +
+
+
Large
+
    +
  1. Home
  2. +
  3. Tasks
  4. +
+
+
Small
+
    +
  1. Home
  2. +
  3. Settings
  4. +
  5. General
  6. +
+
+
+
+
+
+ + +
+

DetailsList & Lists

+

Data tables and list components with selection, grouping, and compact variants.

+
+

DetailsList

+
+
+
+
Name
+
Tags
+
Items
+
Status
+
+
+
+
Project Alpha
+
design, frontend
+
2,341
+
Active
+
+
+
+
Project Beta
+
backend, testing
+
847
+
Pending
+
+
+
+
Project Gamma
+
docs
+
512
+
Error
+
+
+
+
+
+

GroupedList

+
+
+
Active 2
+
+
Project Alpha
+
Project Beta
+
+
+
+
Archived 1
+
+
Project Gamma
+
+
+
+
+
+

List Tokens

+ + + + + +
--listBackground
--listItemBackgroundHovered
--listItemBackgroundChecked
--listText
+
+
+
+ + +
+

Pivots & Nav

+

Tab navigation (Pivots) and hierarchical navigation (Nav) components.

+
+
+

Pivot — Tabs Style

+
+
+ + + + +
+
Panel content for the Edit tab.
+
Panel content for the Preview tab.
+
Panel content for the History tab.
+
+

Pivot — Links Style

+ +
+
+

Nav

+
+
+
Projects
+
+ Alpha + Beta + Gamma +
+
+
+
Reports
+ +
+ Settings + Disabled Link +
+
+
+
+ + +
+

Status & Loading

+

Message bars for status communication, progress indicators, spinners, and loading shimmers.

+
+
+

Message Bars

+
+
This is an informational message.
+
Operation completed successfully.
+
3 items are missing required fields.
+
Storage usage is nearing the quota limit.
+
Failed to connect to the server.
+
Access denied to this resource.
+
+

Status Tokens

+ + + + + +
--infoBackground--infoIcon
--successBackground--successText
--warningBackground--errorText
--errorBackground--errorIcon
+
+
+

Progress

+
+
+
Determinate — 56%
+
+
+
+
Indeterminate
+
+
+
+

Spinner

+
+
xSmall
+
small
+
default
+
large
+
+

Shimmer

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+

Panels & Dialogs

+

Overlay surfaces for detail editing, confirmation dialogs, and modal workflows. Click the buttons to preview.

+
+
+

Panel Sizes

+
+ + + +
+

Panel Tokens

+ + + + +
--overlayBackground
--cardStandoutBackground
--bodyFrameDivider
+
+
+

Dialogs

+
+ + +
+
+
+ + +
+
+

Small Panel

+

Panel body content. Width: small (340px).

+ +
+
+
+

Medium Panel

+

Panel body content. Width: medium (592px).

+ +
+
+
+

Large Panel

+

Panel body content. Width: large (940px).

+ +
+ + +
+
+

Confirm Action

+

Are you sure you want to delete this item? This action cannot be undone.

+ +
+
+
+
+

New Project Wizard

+

This dialog uses the flm-dialog--large-header variant for a taller title area. Ideal for multi-step wizards.

+ +
+
+
+ + +
+

Personas & Cards

+

Identity components and content cards with preview areas and activity indicators.

+
+
+

Persona Sizes

+
+
A
+
AB
Alice B.
+
JD
Jane Doe
Analyst
+
MS
Max Smith
Engineer
Online
+
+

Presence

+
+
A
Available
+
B
Busy
+
C
Away
+
D
Offline
+
E
DND
+
+

Facepile

+
+
AB
+
CD
+
EF
+
GH
+
IJ
+
KL
+
+
+
+

DocumentCard

+
+
+ +
+
+
Quarterly-Report-Q4.pdf
+
+ Project Alpha + Feb 22, 2026 +
+
+
+

Card Tokens

+ + + + + +
--cardStandoutBackground
--cardShadowshadow value
--variantBorder
--variantBorderHovered
+
+
+
+ + +
+

Icons

+

All registered icons from the base library. Use <i class="flm-icon" data-icon="Name"> or register custom SVG paths via FluentIcons.register().

+
+
+ +
+ + + + + + + + + diff --git a/Theme-Gallery.html b/Theme-Gallery.html new file mode 100644 index 0000000..c57ab9d --- /dev/null +++ b/Theme-Gallery.html @@ -0,0 +1,351 @@ + + + + + + FluentLM — Theme Gallery + + + + + + + + + + + + + + + + + + + + + +
+
+
FluentLM
+
Theme Gallery
+
+
+ + + + + + + + + + +
+
+ + +
+ + +
+

Nebula Dusk

+ Purple + Dark +
+ + +

Brand / Accent Ramp

+
+ + +

Neutral Ramp

+
+ + +

Component Preview

+
+
+

Buttons

+
+ + + + +
+
+ + +
+
+
+

Text Fields

+
+
+ + +
+
+ + + Invalid value. +
+
+
+
+

Card Example

+
+
Document Title
+
A card rendered with the current theme's surface and border tokens.
+
+ + +
+
+
+
+

Message Bars

+
+
Informational message.
+
Operation succeeded.
+
Warning: check inputs.
+
Severe warning issued.
+
An error occurred.
+
Access blocked.
+
+
+
+ +
+ + + + + + diff --git a/UPSTREAM_CHANGES.md b/UPSTREAM_CHANGES.md deleted file mode 100644 index e821970..0000000 --- a/UPSTREAM_CHANGES.md +++ /dev/null @@ -1,71 +0,0 @@ -# Upstream Changes to Apply to SynthOS - -## page-v2.js: Fix initial focus race with chat-collapsed check - -The initial `chatInput.focus()` runs before the chat-collapsed localStorage check, -so if the panel was previously collapsed, the input gets focused then immediately -blurred when the panel slides off-screen. The browser moves focus to the body/viewer. - -### Fix - -In `page-scripts/page-v2.js`: - -1. **Remove** the early focus call (around line 100-102): - -```diff -- // 1. Initial focus -- var chatInput = document.getElementById('chatInput'); -- if (chatInput) chatInput.focus(); -+ var chatInput = document.getElementById('chatInput'); -``` - -2. **Add** focus at the very end of the outer IIFE, just before `})();`: - -```diff - })(); -+ -+ // Initial focus — run after all setup (including chat-collapsed check) -+ if (chatInput && !document.body.classList.contains('chat-collapsed')) { -+ chatInput.focus(); -+ } - })(); -``` - -## REQUIRED_PAGES should be derived from the Customizer's requiredPagesFolder - -`REQUIRED_PAGES` in `src/pages.ts` is a hardcoded array (`['builder', 'pages', 'settings', ...]`). -This should instead be dynamically read from the files in the Customizer's `requiredPagesFolder` -directory (strip `.html` extensions to get the page names). Forks that add or rename required pages -(e.g. SynthTabs renames `pages.html` → `tabs.html`) currently have to patch this array, which is -fragile and causes merge conflicts on sync. - -### Suggested approach - -Replace the static constant with a function that scans the folder at startup: - -```ts -// src/pages.ts -export function getRequiredPages(requiredPagesFolder: string): string[] { - // list *.html files in the folder and strip the extension -} -``` - -Cache the result in `SynthOSConfig` so it's computed once during init and passed through -to `useApiRoutes` / `usePageRoutes`. - -## Customizer: add `tabsListRoute` getter - -Added a `tabsListRoute` getter to `Customizer` (default `'/pages'`) so forks can override the -route that the outdated-page redirect targets. SynthTabs overrides this to `'/tabs'`. - -**Files changed:** -- `src/customizer/Customizer.ts` — new getter `tabsListRoute` returning `'/pages'` - -## usePageRoutes: use Customizer for outdated-page redirect - -The outdated-page redirect in `usePageRoutes.ts` was hardcoded to `res.redirect('/pages')`. -Changed to `res.redirect(customizer?.tabsListRoute ?? '/pages')` so forks can control -the redirect destination. - -**Files changed:** -- `src/service/usePageRoutes.ts` — line ~122, redirect now uses `customizer.tabsListRoute` diff --git a/default-pages/application.html b/default-pages/application.html deleted file mode 100644 index 9fbe6f8..0000000 --- a/default-pages/application.html +++ /dev/null @@ -1,40 +0,0 @@ - - - - SynthOS - {Application Title} - - - - - - - - - -
-
SynthOS
-
-
-

SynthOS: what kind of application would you like?

-
-
- -
- - -
-
-
-
{Application Title}
-
{Application Content}
-
-
- - - - - \ No newline at end of file diff --git a/default-pages/application.json b/default-pages/application.json deleted file mode 100644 index 00362db..0000000 --- a/default-pages/application.json +++ /dev/null @@ -1 +0,0 @@ -{ "title": "Application", "categories": ["Starters"], "pinned": false, "showInAll": false, "pageVersion": 2, "mode": "unlocked" } diff --git a/default-pages/application/page.html b/default-pages/application/page.html new file mode 100644 index 0000000..145a5e6 --- /dev/null +++ b/default-pages/application/page.html @@ -0,0 +1,42 @@ + + + + SynthOS - {Application Title} + + + + + + + + + + +
+ + + +
+ +
+
+
Page Builder
+
+
+

SynthOS: what kind of application would you like?

+
+
+
+ +
+
+
+
{Application Title}
+
{Application Content}
+
+
+ + + + + \ No newline at end of file diff --git a/default-pages/application/page.json b/default-pages/application/page.json new file mode 100644 index 0000000..146d286 --- /dev/null +++ b/default-pages/application/page.json @@ -0,0 +1,10 @@ +{ + "title": "Application", + "categories": [ + "Starters" + ], + "pinned": false, + "showInAll": false, + "pageVersion": 3, + "mode": "unlocked" +} diff --git a/default-pages/elevenlabs_effects_studio/page.html b/default-pages/elevenlabs_effects_studio/page.html new file mode 100644 index 0000000..eb91d18 --- /dev/null +++ b/default-pages/elevenlabs_effects_studio/page.html @@ -0,0 +1,1363 @@ + + + + SynthOS - ElevenLabs Effects Studio + + + + + + + +
+ + + +
+ +
+
+
Page Builder
+
+
+

SynthOS: Welcome to the ElevenLabs Effects Studio! Create, manage, and loop sound effects. Make sure the ElevenLabs connector is configured in Settings > Connectors.

+
+
+ +
+
+
+
+ +
+
+ Effects Studio +
+ + +
+ + +
+ + + +
+
+ + + + + +
+
+ + +
+ +
+ + +
+ + Effects Studio + Select an effect from the sidebar to edit it, or click "Add Effect" to create a new sound effect. +
+ + + +
+
+ + +
+
+

Delete Effect

+

Are you sure you want to delete this effect? This cannot be undone.

+ +
+
+ +
+
+ + + + + + diff --git a/default-pages/elevenlabs_effects_studio/page.json b/default-pages/elevenlabs_effects_studio/page.json new file mode 100644 index 0000000..a9ae0fb --- /dev/null +++ b/default-pages/elevenlabs_effects_studio/page.json @@ -0,0 +1,11 @@ +{ + "title": "ElevenLabs Effects Studio", + "categories": [ + "Tools", + "Audio" + ], + "pinned": false, + "showInAll": false, + "pageVersion": 3, + "mode": "locked" +} diff --git a/default-pages/elevenlabs_voice_studio/page.html b/default-pages/elevenlabs_voice_studio/page.html new file mode 100644 index 0000000..ed01619 --- /dev/null +++ b/default-pages/elevenlabs_voice_studio/page.html @@ -0,0 +1,801 @@ + + + + SynthOS - ElevenLabs Voice Studio + + + + + + + +
+ + + +
+ +
+
+
Page Builder
+
+
+

SynthOS: Welcome to the ElevenLabs Voice Studio! Define voice profiles here and they'll be available to all your pages. Make sure the ElevenLabs connector is configured in Settings > Connectors.

+
+
+ +
+
+
+
+ +
+
+ Voices + +
+
+
+ + +
+ +
+ + +
+ + Voice Studio + Select a voice from the sidebar to view its settings and test it, or click "+ New Voice" to create a profile. +
+ + + +
+
+ + +
+
+
+

Select Voice

+ +
+
+
+
+
+
+
+
+
+
+
Loading voices...
+
+
+ +
+ +
+
+ + +
+
+

Delete Voice

+

Are you sure you want to delete this voice profile? This cannot be undone.

+ +
+
+ +
+
+ + + + + + diff --git a/default-pages/elevenlabs_voice_studio/page.json b/default-pages/elevenlabs_voice_studio/page.json new file mode 100644 index 0000000..85a737c --- /dev/null +++ b/default-pages/elevenlabs_voice_studio/page.json @@ -0,0 +1,11 @@ +{ + "title": "ElevenLabs Voice Studio", + "categories": [ + "Tools", + "Audio" + ], + "pinned": false, + "showInAll": false, + "pageVersion": 3, + "mode": "locked" +} diff --git a/default-pages/json_tools.json b/default-pages/json_tools.json deleted file mode 100644 index a818252..0000000 --- a/default-pages/json_tools.json +++ /dev/null @@ -1 +0,0 @@ -{ "title": "JSON Tools", "categories": ["Tools"], "pinned": false, "showInAll": false, "pageVersion": 2, "mode": "unlocked" } diff --git a/default-pages/json_tools.html b/default-pages/json_tools/page.html similarity index 65% rename from default-pages/json_tools.html rename to default-pages/json_tools/page.html index 1f07c51..c8bf74a 100644 --- a/default-pages/json_tools.html +++ b/default-pages/json_tools/page.html @@ -1,89 +1,91 @@ - - - - SynthOS - JSON Tools - - - - - - - - -
-
SynthOS
-
-
-

SynthOS: You can use the dropdown to select a conversion type, enter your text, and click - "Convert" to see the result.

-
-
- -
- - -
-
-
-
JSON Tools
-
-
-
-
-
-
-
-
- - - - - + + + + SynthOS - JSON Tools + + + + + + + + + +
+ + + +
+ +
+
+
Page Builder
+
+
+

SynthOS: You can use the dropdown to select a conversion type, enter your text, and click + "Convert" to see the result.

+
+
+
+ +
+
+
+
JSON Tools
+
+
+
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/default-pages/json_tools/page.json b/default-pages/json_tools/page.json new file mode 100644 index 0000000..fa49d20 --- /dev/null +++ b/default-pages/json_tools/page.json @@ -0,0 +1,10 @@ +{ + "title": "JSON Tools", + "categories": [ + "Tools" + ], + "pinned": false, + "showInAll": false, + "pageVersion": 3, + "mode": "unlocked" +} diff --git a/default-pages/my_notes.html b/default-pages/my_notes.html deleted file mode 100644 index b06f214..0000000 --- a/default-pages/my_notes.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - SynthOS - Notes - - - - - - - - - -
-
SynthOS
-

SynthOS: Welcome to My Notes — a simple note-taking app! Your notes are listed in the sidebar on the left. Click any note to view or edit it, or tap "+ New Note" to create a fresh one. Each note has a rich text editor with formatting tools, and you can save or delete notes using the buttons below the editor. Use the search box to filter notes by title or content. What would you like to do?

- -
- - -
-
-

My Notes

📝

Welcome to Notes

Select a note from the sidebar to view or edit it, or click "+ New Note" to create a new one.

- - - - - - \ No newline at end of file diff --git a/default-pages/my_notes/notes/a1b2c3d4-e5f6-7890-abcd-ef1234567890.json b/default-pages/my_notes/notes/a1b2c3d4-e5f6-7890-abcd-ef1234567890.json new file mode 100644 index 0000000..e9b2b38 --- /dev/null +++ b/default-pages/my_notes/notes/a1b2c3d4-e5f6-7890-abcd-ef1234567890.json @@ -0,0 +1,5 @@ +{ + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "title": "Getting Started with My Notes", + "content": "# Getting Started with My Notes\n\nWelcome to **My Notes** — your personal note-taking space inside SynthOS.\n\n$$toc\n$$\n\n## Creating Notes\n\nClick the **+ New Note** button in the sidebar to create a fresh note. Give it a title in the field at the top, then start writing in the editor below.\n\n## Rich Text Editing\n\nThe editor supports full Markdown formatting:\n\n* **Bold**, *italic*, and ~~strikethrough~~\n* Headings (H1–H6)\n* Bullet lists, numbered lists, and task lists\n* Code blocks, block quotes, and horizontal rules\n* Tables, links, and images\n\nSwitch between the visual (WYSIWYG) editor and the Markdown source using the tabs at the top of the editor.\n\n## Searching and Filtering\n\nUse the **Search** box at the top of the sidebar to filter notes by title. As you type, the list narrows to matching notes.\n\n## Saving and Deleting\n\nAfter editing, click **Save** to persist your changes. To remove a note, click **Delete** — you'll be asked to confirm before anything is removed.\n\n## Table of Contents\n\nFor longer notes, click the **Table of Contents** icon (the indented list icon at the end of the toolbar) to insert an auto-updating TOC. The TOC is scoped to the section where you place it — if inserted under an H1, it lists all H2/H3/etc. children; under an H2, it lists H3/H4/etc. children of that section. The list updates automatically as you add, edit, or remove headings.\n\n***\n\n*Feel free to edit or delete this note — it's here to help you get started!*" +} \ No newline at end of file diff --git a/default-pages/my_notes/page.html b/default-pages/my_notes/page.html new file mode 100644 index 0000000..e5a2a87 --- /dev/null +++ b/default-pages/my_notes/page.html @@ -0,0 +1,132 @@ + + + + SynthOS - Notes + + + + + + + + + + +
+ + + +
+ +
+
+
Page Builder
+

SynthOS: Welcome to My Notes — a simple note-taking app! Your notes are listed in the sidebar on the left. Click any note to view or edit it, or tap "+ New Note" to create a fresh one. Each note has a rich text editor with formatting tools, and you can save or delete notes using the buttons below the editor. Use the search box to filter notes by title or content. What would you like to do?

+
+ +
+
+
My Notes
Welcome to NotesSelect a note from the sidebar to view or edit it, or click "+ New Note" to create a new one.

Delete Note

Are you sure you want to delete this note? This action cannot be undone.

Unsaved Changes

You have unsaved changes. Do you want to discard them?

+ + + + + + \ No newline at end of file diff --git a/default-pages/my_notes.json b/default-pages/my_notes/page.json similarity index 90% rename from default-pages/my_notes.json rename to default-pages/my_notes/page.json index 0ec35b5..7316c1b 100644 --- a/default-pages/my_notes.json +++ b/default-pages/my_notes/page.json @@ -7,6 +7,6 @@ "showInAll": true, "createdDate": "2026-02-13T04:35:31.170Z", "lastModified": "2026-02-14T23:52:47.462Z", - "pageVersion": 2, + "pageVersion": 3, "mode": "locked" -} \ No newline at end of file +} diff --git a/default-pages/neon_asteroids.html b/default-pages/neon_asteroids.html deleted file mode 100644 index 455f722..0000000 --- a/default-pages/neon_asteroids.html +++ /dev/null @@ -1,77 +0,0 @@ - - - - SynthOS - - - - - - - -
-
SynthOS
-
-
-

SynthOS: What can I create for you?

-
-
-

You: create super cool version of astroids...

-
-
-

SynthOS: 🚀 Launching NEON ASTEROIDS! A synthwave-styled space shooter with glowing - neon graphics, particle explosions, screen shake effects, and progressive difficulty. Use WASD or - Arrow keys to move, SPACE to shoot. Destroy asteroids, survive, and chase that high score!

-
-
-

You: great game but a couple of things missing... the rocks should progressively - speed up as the level increases. dropping power ups that are extra lives should be rare. it's also - missing the saucers that fly across the screen and shoot at you starting on level 2

-
-
-

SynthOS: 🛸 Updated! Added: 1) Asteroids now speed up with each level (+15% per - level), 2) Extra life power-ups are now rare (5% chance vs 15% for others), 3) Enemy UFO saucers - appear starting Level 2 - they fly across the screen, track you, and shoot back! Small saucers are - faster and more accurate. Watch out for those glowing red menaces! 👾

-
-
- -
- - -
-
-
- -
-
SCORE: 0
-
LEVEL: 1
-
LIVES: 3
-
-
-

NEON ASTEROIDS

-

Navigate the cosmic void. Destroy all asteroids. Beware the saucers!

- -
- -
WASD / ARROWS to move • SPACE to fire • P to pause
-
-
-
-
- - - - - - - - \ No newline at end of file diff --git a/default-pages/neon_asteroids/files/Ambient_Space.mp3 b/default-pages/neon_asteroids/files/Ambient_Space.mp3 new file mode 100644 index 0000000..2314a8c Binary files /dev/null and b/default-pages/neon_asteroids/files/Ambient_Space.mp3 differ diff --git a/default-pages/neon_asteroids/files/Ambient_Space2.mp3 b/default-pages/neon_asteroids/files/Ambient_Space2.mp3 new file mode 100644 index 0000000..9f5e82c Binary files /dev/null and b/default-pages/neon_asteroids/files/Ambient_Space2.mp3 differ diff --git a/default-pages/neon_asteroids/files/Ambient_Space3.mp3 b/default-pages/neon_asteroids/files/Ambient_Space3.mp3 new file mode 100644 index 0000000..2e046f4 Binary files /dev/null and b/default-pages/neon_asteroids/files/Ambient_Space3.mp3 differ diff --git a/default-pages/neon_asteroids/files/Asteroid_Explosion.mp3 b/default-pages/neon_asteroids/files/Asteroid_Explosion.mp3 new file mode 100644 index 0000000..88e5884 Binary files /dev/null and b/default-pages/neon_asteroids/files/Asteroid_Explosion.mp3 differ diff --git a/default-pages/neon_asteroids/files/Hyperspace_Jump.mp3 b/default-pages/neon_asteroids/files/Hyperspace_Jump.mp3 new file mode 100644 index 0000000..631500c Binary files /dev/null and b/default-pages/neon_asteroids/files/Hyperspace_Jump.mp3 differ diff --git a/default-pages/neon_asteroids/files/Laser_Fire.mp3 b/default-pages/neon_asteroids/files/Laser_Fire.mp3 new file mode 100644 index 0000000..ce3fdd8 Binary files /dev/null and b/default-pages/neon_asteroids/files/Laser_Fire.mp3 differ diff --git a/default-pages/neon_asteroids/files/Menu_Navigate.mp3 b/default-pages/neon_asteroids/files/Menu_Navigate.mp3 new file mode 100644 index 0000000..aeca0a9 Binary files /dev/null and b/default-pages/neon_asteroids/files/Menu_Navigate.mp3 differ diff --git a/default-pages/neon_asteroids/files/Power_Up_Collect.mp3 b/default-pages/neon_asteroids/files/Power_Up_Collect.mp3 new file mode 100644 index 0000000..7fdacd8 Binary files /dev/null and b/default-pages/neon_asteroids/files/Power_Up_Collect.mp3 differ diff --git a/default-pages/neon_asteroids/files/Saucer_Alert.mp3 b/default-pages/neon_asteroids/files/Saucer_Alert.mp3 new file mode 100644 index 0000000..968e73c Binary files /dev/null and b/default-pages/neon_asteroids/files/Saucer_Alert.mp3 differ diff --git a/default-pages/neon_asteroids/files/Ship_Thrust.mp3 b/default-pages/neon_asteroids/files/Ship_Thrust.mp3 new file mode 100644 index 0000000..ab386f3 Binary files /dev/null and b/default-pages/neon_asteroids/files/Ship_Thrust.mp3 differ diff --git a/default-pages/neon_asteroids/files/effects.json b/default-pages/neon_asteroids/files/effects.json new file mode 100644 index 0000000..968cf3a --- /dev/null +++ b/default-pages/neon_asteroids/files/effects.json @@ -0,0 +1,74 @@ +{ + "effects": [ + { + "name": "Laser_Fire", + "file": "Laser_Fire.mp3", + "loop": { "enabled": false, "ambient": false, "minDelay": 0, "maxDelay": 0, "repeat": 0 }, + "duration": 0.5, + "influence": 35 + }, + { + "name": "Asteroid_Explosion", + "file": "Asteroid_Explosion.mp3", + "loop": { "enabled": false, "ambient": false, "minDelay": 0, "maxDelay": 0, "repeat": 0 }, + "duration": 1.2, + "influence": 40 + }, + { + "name": "Ship_Thrust", + "file": "Ship_Thrust.mp3", + "loop": { "enabled": true, "ambient": false, "minDelay": 0, "maxDelay": 0, "repeat": 0 }, + "duration": 15, + "influence": 30 + }, + { + "name": "Hyperspace_Jump", + "file": "Hyperspace_Jump.mp3", + "loop": { "enabled": false, "ambient": false, "minDelay": 0, "maxDelay": 0, "repeat": 0 }, + "duration": 1.5, + "influence": 45 + }, + { + "name": "Power_Up_Collect", + "file": "Power_Up_Collect.mp3", + "loop": { "enabled": false, "ambient": false, "minDelay": 0, "maxDelay": 0, "repeat": 0 }, + "duration": 0.8, + "influence": 35 + }, + { + "name": "Saucer_Alert", + "file": "Saucer_Alert.mp3", + "loop": { "enabled": false, "ambient": false, "minDelay": 0, "maxDelay": 0, "repeat": 0 }, + "duration": 1, + "influence": 40 + }, + { + "name": "Menu_Navigate", + "file": "Menu_Navigate.mp3", + "loop": { "enabled": false, "ambient": false, "minDelay": 0, "maxDelay": 0, "repeat": 0 }, + "duration": 0, + "influence": 30 + }, + { + "name": "Ambient_Space", + "file": "Ambient_Space.mp3", + "loop": { "enabled": true, "ambient": true, "minDelay": 0, "maxDelay": 0, "repeat": 0 }, + "duration": 15, + "influence": 25 + }, + { + "name": "Ambient_Space2", + "file": "Ambient_Space2.mp3", + "loop": { "enabled": false, "ambient": false, "minDelay": 0, "maxDelay": 0, "repeat": 0 }, + "duration": 15, + "influence": 30 + }, + { + "name": "Ambient_Space3", + "file": "Ambient_Space3.mp3", + "loop": { "enabled": true, "ambient": true, "minDelay": 0, "maxDelay": 0, "repeat": 0 }, + "duration": 15, + "influence": 30 + } + ] +} diff --git a/default-pages/neon_asteroids/page.html b/default-pages/neon_asteroids/page.html new file mode 100644 index 0000000..1af22b2 --- /dev/null +++ b/default-pages/neon_asteroids/page.html @@ -0,0 +1,1822 @@ + + + + SynthOS + + + + + + + + +
+ + + +
+ +
+
+
Page Builder
+
+
+

SynthOS: NEON ASTEROIDS — Destroy asteroids, dodge saucers, collect power-ups. Controls: WASD/Arrows to move, Space to fire, H for hyperspace, P to pause. Gamepad supported. Up to 4 players in Pass & Play mode.

+

Want to build your own retro game? Start from the Retro Game Starter — it gives you a ready-made canvas, game loop, and input handling so you can jump straight into designing your game.

+
+

User: Integrate sound effects from the uploaded effects.json manifest into the game — laser fire, explosions, thrust, hyperspace, power-ups, saucer alerts, menu navigation, and ambient space sounds.

SynthOS: Sound effects integrated! Here's what I added: +
Laser Fire — plays each time you shoot +
Asteroid Explosion — plays when asteroids are destroyed +
Ship Thrust — loops while you're thrusting, stops when you release +
Hyperspace Jump — plays on teleport +
Power-Up Collect — plays when you pick up any power-up +
Saucer Alert — plays when an enemy saucer spawns +
Menu Navigate — plays when moving through menu options +
Ambient Space — three ambient tracks randomly cycle as background atmosphere during gameplay +

All sounds load asynchronously on page load. The ambient soundscape starts when gameplay begins and stops on game over or return to menu. Close the chat panel and start a mission to hear them!

User: the sound effects aren't playing

SynthOS: Let me check the sound system. I've added error handling to diagnose the issue. Open your browser's console (F12) and look for messages about sound loading.

Common issues:
Missing manifest — The effects.json file needs to be uploaded to this page's file storage
Missing audio files — Each sound referenced in effects.json must also be uploaded
Browser autoplay policy — Some browsers block audio until user interaction

To fix:
1. Make sure effects.json is uploaded via the Files API
2. Upload all referenced .mp3/.ogg files
3. Check the console for specific error messages

The sound system will now log detailed loading info to help debug!

User: Fix the JavaScript errors — SyntaxError: Unexpected end of input and TypeError: Cannot read properties of null.

SynthOS: Found and fixed the bug! The stopAmbient() function was missing its closing brace }, which caused a SyntaxError that prevented the entire game script from parsing. This cascaded into the null reference error in gameLoop(). The game should now load and run correctly.

User: Fix the JavaScript error: SyntaxError: Identifier 'sfx' has already been declared

SynthOS: Fixed! The issue was that the inline game script used const and let for top-level variable declarations. Since retro-game.js (the library) likely declares some of the same identifiers, using const/let causes a "already been declared" error because they don't allow redeclaration in the same scope. I've changed all top-level const and let declarations to var, which allows redeclaration without errors. The game should now load and run correctly.

Saved
+ +
+ +
+
+
+
+ +
+ +
+
SCORE: 00000
+
LEVEL: 1
+
LIVES: 3
+ +
+
PAUSED
+
+
PLAYER 1
+
YOUR TURN
+
Press SPACE to start
+
+
+
GAME OVER
+
FINAL STANDINGS
+
+ +
+
+

NEON
ASTEROIDS

+

Navigate the cosmic void. Destroy all asteroids. Beware the saucers!

+
+ + +
+
+
CHOOSE MODE
+ +
Each player gets a fresh game. Highest total score wins.
+ +
Shared asteroid field. Rotate on each death. Last one standing!
+
+
+
PLAYERS
+
+ + 2 + +
+ +
+
+ +
+

NEW HIGH SCORE!

+
SCORE: 0
+
+
A
+
A
+
A
+
+
LEFT/RIGHT select slot • UP/DOWN change letter • ENTER confirm
+
+
WASD / ARROWS to move • SPACE to fire • H hyperspace • P pause
+
+ +
+
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/default-pages/neon_asteroids.json b/default-pages/neon_asteroids/page.json similarity index 55% rename from default-pages/neon_asteroids.json rename to default-pages/neon_asteroids/page.json index 37e2353..c101675 100644 --- a/default-pages/neon_asteroids.json +++ b/default-pages/neon_asteroids/page.json @@ -5,8 +5,8 @@ ], "pinned": false, "showInAll": true, - "createdDate": "2026-02-13T03:18:30.368Z", - "lastModified": "2026-02-13T03:20:42.803Z", - "pageVersion": 2, + "createdDate": "2026-03-02T21:38:41.912Z", + "lastModified": "2026-03-02T21:38:41.912Z", + "pageVersion": 3, "mode": "unlocked" } \ No newline at end of file diff --git a/default-pages/oregon_trail.html b/default-pages/oregon_trail/page.html similarity index 74% rename from default-pages/oregon_trail.html rename to default-pages/oregon_trail/page.html index 02c7ac0..05deda2 100644 --- a/default-pages/oregon_trail.html +++ b/default-pages/oregon_trail/page.html @@ -1,321 +1,323 @@ - - - - SynthOS - - - - - - - - - -
-
SynthOS
-
-

SynthOS: What can I create for you? Ask "what can you do?" or "how does this work?" to learn more. Remember to save often!

-

User: create a map of the united states

SynthOS: Here's an interactive map of the United States! Hover over any state to see its name. The map is built with D3.js and uses an Albers USA projection that includes Alaska and Hawaii.

User: now in a second layer can you overlay that with a map of the usa circa 1850s

SynthOS: I've overlaid the modern map with an 1850s historical layer! The overlay shows the political divisions of the United States around 1850 (after the Compromise of 1850):

  • Green — States admitted to the Union by 1850
  • Orange — Organized territories
  • Brown — Unorganized territory

You can toggle the historical overlay on and off with the button in the top-right corner. The modern state boundaries show through underneath for reference.

User: remove the current state boundaries and draw new boundaries for how the territories were organized

SynthOS: I've replaced the modern state boundaries with historically-grouped 1850s territorial boundaries. The map now shows the approximate political divisions of the United States circa 1850, with internal state lines dissolved within each territory:

  • Green — Individual states (31 states admitted by 1850)
  • Orange — Oregon Territory, Minnesota Territory, Utah Territory, New Mexico Territory
  • Brown — Unorganized Territory & Indian Territory

Note: Since this uses modern state shapes as approximations, the boundaries aren't perfectly historically accurate (e.g., territories that split modern states), but it gives a good sense of how the country was organized. Hover over any region to see its 1850s designation.

User: now overlay that with the oregon trail

SynthOS: I've overlaid the Oregon Trail on the 1850s map! The trail is shown as a red dashed line running from Independence, Missouri to Oregon City, Oregon — roughly 2,000 miles.

Key stops along the trail are marked with dots and labels:

  • Independence, MO — the main jumping-off point
  • Fort Kearny — first major military outpost
  • Chimney Rock — famous landmark
  • Fort Laramie — key resupply point
  • Independence Rock — "Register of the Desert"
  • South Pass — crossing the Continental Divide
  • Fort Bridger — trading post
  • Fort Hall — Hudson's Bay Company post
  • Fort Boise — another HBC post
  • The Dalles — end of the overland route
  • Oregon City — final destination

Hover over any stop for its name. The trail follows the approximate historical route through the Platte River valley, over the Rockies at South Pass, and along the Snake River into Oregon.

User: create a detail card that tells me facts about key stops along the way

SynthOS: I've added an interactive detail card! Click on any trail stop marker on the map to see historical facts about that location, including when it was established, its elevation, who used it, and why it mattered to Oregon Trail emigrants. Click the × button or click elsewhere to dismiss the card.

User: can you add an AI powered chat interface to that card that lets me ask questions relative to that location and the journey as a whole. the target audience should be 8-12 year olds. track the last 10 turns for follow up questions. reorganize the card if you need to

SynthOS: I've added an AI-powered chat to the detail card! Here's what's new:

  • Two tabs — "📋 Facts" shows the historical info, and "💬 Ask Me!" opens the chat
  • Meet Dusty 🤠 — your friendly trail guide who answers questions in fun, kid-friendly language (perfect for ages 8–12)
  • Suggested questions — each stop has starter questions to tap, or kids can type their own
  • Conversation memory — Dusty remembers the last 10 exchanges so kids can ask follow-up questions
  • Per-stop context — switching to a new stop resets the chat with fresh context about that location

Click any trail stop, then hit the "Ask Me!" tab to start chatting with Dusty!

- -
- - -
-
-
United States — Circa 1850 — with Oregon Trail
Circa 1850
States (31)
Oregon Territory
Minnesota Territory
Utah Territory
New Mexico Territory
Unorganized Territory
Indian Territory
Oregon Trail

🤠Howdy, explorer! Click a trail stop and ask me anything about life on the Oregon Trail!
- - - - - - - \ No newline at end of file + + + + SynthOS + + + + + + + + + + +
+ + + +
+ +
+
+
Page Builder
+
+

SynthOS: What can I create for you? Ask "what can you do?" or "how does this work?" to learn more. Remember to save often!

+

User: create a map of the united states

SynthOS: Here's an interactive map of the United States! Hover over any state to see its name. The map is built with D3.js and uses an Albers USA projection that includes Alaska and Hawaii.

User: now in a second layer can you overlay that with a map of the usa circa 1850s

SynthOS: I've overlaid the modern map with an 1850s historical layer! The overlay shows the political divisions of the United States around 1850 (after the Compromise of 1850):

  • Green — States admitted to the Union by 1850
  • Orange — Organized territories
  • Brown — Unorganized territory

You can toggle the historical overlay on and off with the button in the top-right corner. The modern state boundaries show through underneath for reference.

User: remove the current state boundaries and draw new boundaries for how the territories were organized

SynthOS: I've replaced the modern state boundaries with historically-grouped 1850s territorial boundaries. The map now shows the approximate political divisions of the United States circa 1850, with internal state lines dissolved within each territory:

  • Green — Individual states (31 states admitted by 1850)
  • Orange — Oregon Territory, Minnesota Territory, Utah Territory, New Mexico Territory
  • Brown — Unorganized Territory & Indian Territory

Note: Since this uses modern state shapes as approximations, the boundaries aren't perfectly historically accurate (e.g., territories that split modern states), but it gives a good sense of how the country was organized. Hover over any region to see its 1850s designation.

User: now overlay that with the oregon trail

SynthOS: I've overlaid the Oregon Trail on the 1850s map! The trail is shown as a red dashed line running from Independence, Missouri to Oregon City, Oregon — roughly 2,000 miles.

Key stops along the trail are marked with dots and labels:

  • Independence, MO — the main jumping-off point
  • Fort Kearny — first major military outpost
  • Chimney Rock — famous landmark
  • Fort Laramie — key resupply point
  • Independence Rock — "Register of the Desert"
  • South Pass — crossing the Continental Divide
  • Fort Bridger — trading post
  • Fort Hall — Hudson's Bay Company post
  • Fort Boise — another HBC post
  • The Dalles — end of the overland route
  • Oregon City — final destination

Hover over any stop for its name. The trail follows the approximate historical route through the Platte River valley, over the Rockies at South Pass, and along the Snake River into Oregon.

User: create a detail card that tells me facts about key stops along the way

SynthOS: I've added an interactive detail card! Click on any trail stop marker on the map to see historical facts about that location, including when it was established, its elevation, who used it, and why it mattered to Oregon Trail emigrants. Click the × button or click elsewhere to dismiss the card.

User: can you add an AI powered chat interface to that card that lets me ask questions relative to that location and the journey as a whole. the target audience should be 8-12 year olds. track the last 10 turns for follow up questions. reorganize the card if you need to

SynthOS: I've added an AI-powered chat to the detail card! Here's what's new:

  • Two tabs — "📋 Facts" shows the historical info, and "💬 Ask Me!" opens the chat
  • Meet Dusty 🤠 — your friendly trail guide who answers questions in fun, kid-friendly language (perfect for ages 8–12)
  • Suggested questions — each stop has starter questions to tap, or kids can type their own
  • Conversation memory — Dusty remembers the last 10 exchanges so kids can ask follow-up questions
  • Per-stop context — switching to a new stop resets the chat with fresh context about that location

Click any trail stop, then hit the "Ask Me!" tab to start chatting with Dusty!

+
+ +
+
+
United States — Circa 1850 — with Oregon Trail
Circa 1850
States (31)
Oregon Territory
Minnesota Territory
Utah Territory
New Mexico Territory
Unorganized Territory
Indian Territory
Oregon Trail

🤠Howdy, explorer! Click a trail stop and ask me anything about life on the Oregon Trail!
+ + + + + + + \ No newline at end of file diff --git a/default-pages/oregon_trail.json b/default-pages/oregon_trail/page.json similarity index 90% rename from default-pages/oregon_trail.json rename to default-pages/oregon_trail/page.json index f76826e..bb0749f 100644 --- a/default-pages/oregon_trail.json +++ b/default-pages/oregon_trail/page.json @@ -7,6 +7,6 @@ "showInAll": false, "createdDate": "2026-02-16T13:52:41.911Z", "lastModified": "2026-02-16T13:52:41.911Z", - "pageVersion": 2, + "pageVersion": 3, "mode": "locked" -} \ No newline at end of file +} diff --git a/default-pages/retro_game_starter/page.html b/default-pages/retro_game_starter/page.html new file mode 100644 index 0000000..9354a98 --- /dev/null +++ b/default-pages/retro_game_starter/page.html @@ -0,0 +1,1308 @@ + + + + SynthOS + + + + + + + + +
+ + + +
+ +
+
+
Page Builder
+
+
+

SynthOS: Welcome to the Retro Game Starter! This is a starting point for building your game.

+

1. Pick a game idea. Not sure? Try “brainstorm 5 retro game ideas” and I’ll give you options.

+

2. Tell me to build it. Describe what you want: “Turn this into a Space Invaders clone” or “Make a snake game with neon graphics.” Upload a screenshot for reference if you have one.

+

3. Play and iterate. Try it out, then tell me what to change: “Make the enemies faster”, “Add a power-up that gives a shield.”

+

4. Add sound effects. Open the ElevenLabs Effects Studio page, select this game as the project, click Scan to auto-generate effects, then Apply to wire them in.

+

Go ahead — tell me what you want to build!

+
+
+ +
+
+
+
+ +
+ +
+
SCORE: 00000
+
LIVES: 3
+ +
+
PAUSED
+
+
PLAYER 1
+
YOUR TURN
+
Press SPACE to start
+
+
+
GAME OVER
+
FINAL STANDINGS
+
+ +
+
+

PADDLE
BOUNCE

+

Keep the ball alive! Bounce it off your paddle to score.

+
+ + +
+
+
PLAYERS
+
+ + 2 + +
+ +
+
+ +
+

NEW HIGH SCORE!

+
SCORE: 0
+
+
A
+
A
+
A
+
+
LEFT/RIGHT select slot • UP/DOWN change letter • ENTER confirm
+
+
←/→ move paddle • P pause
+
+ +
+
+
+
+
+ + + + + + + + + diff --git a/default-pages/retro_game_starter/page.json b/default-pages/retro_game_starter/page.json new file mode 100644 index 0000000..945730a --- /dev/null +++ b/default-pages/retro_game_starter/page.json @@ -0,0 +1,12 @@ +{ + "title": "Retro Game Starter", + "categories": [ + "Starters" + ], + "pinned": false, + "showInAll": true, + "createdDate": "2026-03-02T12:00:00.000Z", + "lastModified": "2026-03-02T12:00:00.000Z", + "pageVersion": 3, + "mode": "unlocked" +} diff --git a/default-pages/sidebar_page.json b/default-pages/sidebar_page.json deleted file mode 100644 index adcc45e..0000000 --- a/default-pages/sidebar_page.json +++ /dev/null @@ -1 +0,0 @@ -{ "title": "Sidebar Page", "categories": ["Starters"], "pinned": false, "showInAll": false, "pageVersion": 2, "mode": "unlocked" } diff --git a/default-pages/sidebar_page.html b/default-pages/sidebar_page/page.html similarity index 59% rename from default-pages/sidebar_page.html rename to default-pages/sidebar_page/page.html index ffd7c0e..4468e36 100644 --- a/default-pages/sidebar_page.html +++ b/default-pages/sidebar_page/page.html @@ -1,49 +1,51 @@ - - - - SynthOS - - - - - - - -
-
SynthOS
-
-

SynthOS: Welcome to the Sidebar template! You can ask me to modify the content area on the left or customize the sidebar on the right. Just let me know what you'd like to create.

-
- -
- - -
-
-
Your content will appear here
Ask SynthOS to add something!
- -
-
- - - - - \ No newline at end of file + + + + SynthOS + + + + + + + + +
+ + + +
+ +
+
+
Page Builder
+
+

SynthOS: Welcome to the Sidebar template! You can ask me to modify the content area on the left or customize the sidebar on the right. Just let me know what you'd like to create.

+
+
+ +
+
+
Your content will appear here
Ask SynthOS to add something!
+ +
+
+ + + + + \ No newline at end of file diff --git a/default-pages/sidebar_page/page.json b/default-pages/sidebar_page/page.json new file mode 100644 index 0000000..439c1cb --- /dev/null +++ b/default-pages/sidebar_page/page.json @@ -0,0 +1,10 @@ +{ + "title": "Sidebar Page", + "categories": [ + "Starters" + ], + "pinned": false, + "showInAll": false, + "pageVersion": 3, + "mode": "unlocked" +} diff --git a/default-pages/solar_explorer.html b/default-pages/solar_explorer/page.html similarity index 93% rename from default-pages/solar_explorer.html rename to default-pages/solar_explorer/page.html index 07321e4..a09f8d8 100644 --- a/default-pages/solar_explorer.html +++ b/default-pages/solar_explorer/page.html @@ -1,1948 +1,1951 @@ - - - - SynthOS - - - - - - - - - -
-
SynthOS
-
-

SynthOS: Welcome to the Solar Explorer

-

Main View Controls:

-
    -
  • Hover over any planet to see quick facts
  • -
  • Click a planet to enter its detail view with moons
  • -
  • Use the speed slider (top-left) to control time
  • -
  • Toggle orbits, labels, and asteroids with the bottom toolbar
  • -
-

Detail View:

-
    -
  • Watch moons orbit realistically around the planet
  • -
  • Hover over moons for info, click to ask AI about them
  • -
  • Use the moon speed slider to control orbital animation
  • -
  • Ask questions in the sidebar input box
  • -
-

Solar Explorer Panel (top-right): Ask any question about the solar system!

-

Try clicking on Jupiter or Saturn to see their impressive moon systems! 🪐

-
- -
- - -
-
-
-
-
-
-
-
Type
-
Diameter
-
Moons
-
- Fun Fact - -
-
- -
-
-
- -
-
-
Diameter
-
Distance
-
Orbital Period
-
-
-
-
-
- -
-
-
-
-
-
Type
-
Diameter
-
Distance from Sun
-
Orbital Period
-
Day Length
-
Surface Temp
-
Moons
-
- -
-
-
-
- -
- - -
-
- ~60 days / sec - Paused -
- -
-

Solar Explorer

- - -
-
- - - - - -
-
-
- - - - - - - \ No newline at end of file + + + + SynthOS + + + + + + + + + + + +
+ + + +
+ +
+
+
Page Builder
+
+

SynthOS: Welcome to the Solar Explorer

+

Main View Controls:

+
    +
  • Hover over any planet to see quick facts
  • +
  • Click a planet to enter its detail view with moons
  • +
  • Use the speed slider (top-left) to control time
  • +
  • Toggle orbits, labels, and asteroids with the bottom toolbar
  • +
+

Detail View:

+
    +
  • Watch moons orbit realistically around the planet
  • +
  • Hover over moons for info, click to ask AI about them
  • +
  • Use the moon speed slider to control orbital animation
  • +
  • Ask questions in the sidebar input box
  • +
+

Solar Explorer Panel (top-right): Ask any question about the solar system!

+

Try clicking on Jupiter or Saturn to see their impressive moon systems! 🪐

+
+
+ +
+
+
+
+
+
+
+
Type
+
Diameter
+
Moons
+
+ Fun Fact + +
+
+ +
+
+
+ +
+
+
Diameter
+
Distance
+
Orbital Period
+
+
+
+
+
+ +
+
+
+
+
+
Type
+
Diameter
+
Distance from Sun
+
Orbital Period
+
Day Length
+
Surface Temp
+
Moons
+
+ +
+
+
+
+ +
+ + +
+
+ ~60 days / sec + Paused +
+ +
+

Solar Explorer

+ + +
+
+ + + + + +
+
+
+ + + + + + + \ No newline at end of file diff --git a/default-pages/solar_explorer.json b/default-pages/solar_explorer/page.json similarity index 91% rename from default-pages/solar_explorer.json rename to default-pages/solar_explorer/page.json index 24e9f78..52d0e82 100644 --- a/default-pages/solar_explorer.json +++ b/default-pages/solar_explorer/page.json @@ -7,6 +7,6 @@ "showInAll": false, "createdDate": "2026-02-15T11:12:27.724Z", "lastModified": "2026-02-15T11:12:27.724Z", - "pageVersion": 2, + "pageVersion": 3, "mode": "locked" -} \ No newline at end of file +} diff --git a/default-pages/solar_tutorial.json b/default-pages/solar_tutorial.json deleted file mode 100644 index 4cb2854..0000000 --- a/default-pages/solar_tutorial.json +++ /dev/null @@ -1 +0,0 @@ -{ "title": "Solar Tutorial", "categories": ["Tutorials"], "pinned": false, "showInAll": true, "pageVersion": 2, "mode": "unlocked" } diff --git a/default-pages/solar_tutorial.html b/default-pages/solar_tutorial/page.html similarity index 88% rename from default-pages/solar_tutorial.html rename to default-pages/solar_tutorial/page.html index 24cec70..2d981c6 100644 --- a/default-pages/solar_tutorial.html +++ b/default-pages/solar_tutorial/page.html @@ -1,476 +1,478 @@ - - - - SynthOS - - - - - - - - - -
-
SynthOS
-
-

SynthOS: Welcome to this Solar Tutorial! I'll guide you through building interactive features for this simulation of the Solar System. Let's start with something simple: add a reverse time button so users can watch planets orbit backwards!

- - -
- -
- - -
-
-
-
-
-
-
-
Type
-
Diameter
-
Moons
-
- Fun Fact - -
-
- -
-
-
- -
-
-
Diameter
-
Distance
-
Orbital Period
-
-
-
-
- -
-
-
-
-
-
Type
-
Diameter
-
Distance from Sun
-
Orbital Period
-
Day Length
-
Surface Temp
-
Moons
-
- Fun Fact - -
-
- -
-
-
- - -
-
-

Solar Tutorial

-

Click on any planet to see detailed information.

-

The simulation shows accurate relative orbital periods and distances (scaled for visibility).

-
-
- - - - -
-
-
- - - - - - - \ No newline at end of file + + + + SynthOS + + + + + + + + + + +
+ + + +
+ +
+
+
Page Builder
+
+

SynthOS: Welcome to this Solar Tutorial! I'll guide you through building interactive features for this simulation of the Solar System. Let's start with something simple: add a reverse time button so users can watch planets orbit backwards!

+ + +
+
+ +
+
+
+
+
+
+
+
Type
+
Diameter
+
Moons
+
+ Fun Fact + +
+
+ +
+
+
+ +
+
+
Diameter
+
Distance
+
Orbital Period
+
+
+
+
+ +
+
+
+
+
+
Type
+
Diameter
+
Distance from Sun
+
Orbital Period
+
Day Length
+
Surface Temp
+
Moons
+
+ Fun Fact + +
+
+ +
+
+
+ + +
+
+

Solar Tutorial

+

Click on any planet to see detailed information.

+

The simulation shows accurate relative orbital periods and distances (scaled for visibility).

+
+
+ + + + +
+
+
+ + + + + + + \ No newline at end of file diff --git a/default-pages/solar_tutorial/page.json b/default-pages/solar_tutorial/page.json new file mode 100644 index 0000000..106f592 --- /dev/null +++ b/default-pages/solar_tutorial/page.json @@ -0,0 +1,10 @@ +{ + "title": "Solar Tutorial", + "categories": [ + "Tutorials" + ], + "pinned": false, + "showInAll": true, + "pageVersion": 3, + "mode": "unlocked" +} diff --git a/default-pages/two-panel_page.json b/default-pages/two-panel_page.json deleted file mode 100644 index be0ec4d..0000000 --- a/default-pages/two-panel_page.json +++ /dev/null @@ -1 +0,0 @@ -{ "title": "Two-Panel Page", "categories": ["Starters"], "pinned": false, "showInAll": false, "pageVersion": 2, "mode": "unlocked" } diff --git a/default-pages/two-panel_page.html b/default-pages/two-panel_page/page.html similarity index 70% rename from default-pages/two-panel_page.html rename to default-pages/two-panel_page/page.html index a05fd96..95c7c91 100644 --- a/default-pages/two-panel_page.html +++ b/default-pages/two-panel_page/page.html @@ -1,66 +1,68 @@ - - - - SynthOS - Split Panel Application - - - - - - - - -
-
SynthOS
-
-

SynthOS: Welcome to the Two-Panel layout template! By default, panels are stacked top/bottom (horizontal splitter). You can ask me to switch to left/right (vertical splitter) at any time. Just tell me what you'd like in each panel!

-
- -
- - -
-
-
-
-
Split Panel Application
-
-
-
-
-
Top Panel
-
Ask SynthOS to add content here
-
-
-
-
-
-
-
Bottom Panel
-
Ask SynthOS to add content here
-
-
-
-
-
-
- - - - - + + + + SynthOS - Split Panel Application + + + + + + + + + +
+ + + +
+ +
+
+
Page Builder
+
+

SynthOS: Welcome to the Two-Panel layout template! By default, panels are stacked top/bottom (horizontal splitter). You can ask me to switch to left/right (vertical splitter) at any time. Just tell me what you'd like in each panel!

+
+
+ +
+
+
+
+
Split Panel Application
+
+
+
+
+
Top Panel
+
Ask SynthOS to add content here
+
+
+
+
+
+
+
Bottom Panel
+
Ask SynthOS to add content here
+
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/default-pages/two-panel_page/page.json b/default-pages/two-panel_page/page.json new file mode 100644 index 0000000..1410a8a --- /dev/null +++ b/default-pages/two-panel_page/page.json @@ -0,0 +1,10 @@ +{ + "title": "Two-Panel Page", + "categories": [ + "Starters" + ], + "pinned": false, + "showInAll": false, + "pageVersion": 3, + "mode": "unlocked" +} diff --git a/default-pages/us_map.html b/default-pages/us_map/page.html similarity index 67% rename from default-pages/us_map.html rename to default-pages/us_map/page.html index 3d589bc..e824d91 100644 --- a/default-pages/us_map.html +++ b/default-pages/us_map/page.html @@ -8,6 +8,7 @@ + +
+ + + +
+ +
-
SynthOS
+
Page Builder

SynthOS: Here's your interactive US map! You can customize it by adding data to visualize — just tell me what you'd like to overlay. Here are a couple of ideas:

    @@ -89,15 +97,8 @@
What data would you like to see on the map?

-
- -
- - +
+
@@ -189,4 +190,4 @@

United States

initMap(); })(); - \ No newline at end of file + \ No newline at end of file diff --git a/default-pages/us_map.json b/default-pages/us_map/page.json similarity index 90% rename from default-pages/us_map.json rename to default-pages/us_map/page.json index d0a0244..857bf6a 100644 --- a/default-pages/us_map.json +++ b/default-pages/us_map/page.json @@ -7,6 +7,6 @@ "showInAll": false, "createdDate": "2026-02-17T16:47:12.135Z", "lastModified": "2026-02-17T16:51:44.761Z", - "pageVersion": 2, + "pageVersion": 3, "mode": "unlocked" -} \ No newline at end of file +} diff --git a/default-pages/us_map_1850.html b/default-pages/us_map_1850/page.html similarity index 65% rename from default-pages/us_map_1850.html rename to default-pages/us_map_1850/page.html index d2b0466..9796932 100644 --- a/default-pages/us_map_1850.html +++ b/default-pages/us_map_1850/page.html @@ -8,11 +8,19 @@ + - + +
+ + + +
+ +
-
SynthOS
+
Page Builder

SynthOS: Welcome! This is an interactive map of the United States circa the 1850s, showing how the country was politically organized after the Compromise of 1850. It's designed for interactive exploration — here are a few things you could try:

    @@ -23,15 +31,8 @@
  • Resource & agriculture map — Show where key crops, minerals, and natural resources were concentrated
Just tell me what you'd like to explore!

-
- -
- - +
+
United States — Circa 1850
Circa 1850
States (31)
Oregon Territory
Minnesota Territory
Utah Territory
New Mexico Territory
Unorganized Territory
Indian Territory

@@ -316,10 +317,10 @@ e.stopPropagation();sendChat(document.getElementById('detail-chat-input').value); }); document.getElementById('detail-chat-input').addEventListener('keydown',function(e){ -if(e.key==='Enter'){e.preventDefault();e.stopPropagation();sendChat(this.value);} +if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();e.stopPropagation();sendChat(this.value);} }); document.getElementById('detail-chat-input').addEventListener('click',function(e){e.stopPropagation();}); /* ─── INIT ────────────────────────────────────────────────────────── */ waitForMap(); -})(); \ No newline at end of file +})(); \ No newline at end of file diff --git a/default-pages/us_map_1850.json b/default-pages/us_map_1850/page.json similarity index 90% rename from default-pages/us_map_1850.json rename to default-pages/us_map_1850/page.json index 4409a0c..8d45889 100644 --- a/default-pages/us_map_1850.json +++ b/default-pages/us_map_1850/page.json @@ -7,6 +7,6 @@ "showInAll": false, "createdDate": "2026-02-17T17:01:32.834Z", "lastModified": "2026-02-17T17:09:27.773Z", - "pageVersion": 2, + "pageVersion": 3, "mode": "unlocked" -} \ No newline at end of file +} diff --git a/default-pages/western_cities_1850.html b/default-pages/western_cities_1850/page.html similarity index 74% rename from default-pages/western_cities_1850.html rename to default-pages/western_cities_1850/page.html index 040caf4..8cc7b00 100644 --- a/default-pages/western_cities_1850.html +++ b/default-pages/western_cities_1850/page.html @@ -8,11 +8,19 @@ + - + +
+ + + +
+ +
-
SynthOS
+
Page Builder

SynthOS: Welcome! This is an interactive map of the United States circa the 1850s, showing how the country was politically organized after the Compromise of 1850. It's designed for interactive exploration — here are a few things you could try:

    @@ -23,15 +31,8 @@
  • Resource & agriculture map — Show where key crops, minerals, and natural resources were concentrated
Just tell me what you'd like to explore!

-

User: Add all of the major towns or cities out west in 1850 with a styled detail card that shows facts about each.

SynthOS: I've added 18 major western cities and towns from the 1850s era to the map! Each one appears as a glowing marker dot with a label. Click any marker to open a detailed card with:

  • 📋 Facts tab — Population, founding date, economy, historical role, and more
  • 💬 Ask Me tab — Chat with an AI historian about that specific location
  • Suggested questions — Quick-start prompts tailored to each city

Cities include: San Francisco, Sacramento, Salt Lake City, Santa Fe, Portland, Oregon City, Los Angeles, San Diego, Independence, Council Bluffs, St. Joseph, Monterey, Tucson, Olympia, Stockton, El Paso, Astoria, and San Jose. Try zooming in to explore the western regions!

- -
- - +

User: Add all of the major towns or cities out west in 1850 with a styled detail card that shows facts about each.

SynthOS: I've added 18 major western cities and towns from the 1850s era to the map! Each one appears as a glowing marker dot with a label. Click any marker to open a detailed card with:

  • 📋 Facts tab — Population, founding date, economy, historical role, and more
  • 💬 Ask Me tab — Chat with an AI historian about that specific location
  • Suggested questions — Quick-start prompts tailored to each city

Cities include: San Francisco, Sacramento, Salt Lake City, Santa Fe, Portland, Oregon City, Los Angeles, San Diego, Independence, Council Bluffs, St. Joseph, Monterey, Tucson, Olympia, Stockton, El Paso, Astoria, and San Jose. Try zooming in to explore the western regions!

+
United States — Circa 1850
Circa 1850
States (31)
Oregon Territory
Minnesota Territory
Utah Territory
New Mexico Territory
Unorganized Territory
Indian Territory

@@ -518,9 +519,9 @@ e.stopPropagation();sendChat(document.getElementById('detail-chat-input').value); }); document.getElementById('detail-chat-input').addEventListener('keydown',function(e){ -if(e.key==='Enter'){e.preventDefault();e.stopPropagation();sendChat(this.value);} +if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();e.stopPropagation();sendChat(this.value);} }); document.getElementById('detail-chat-input').addEventListener('click',function(e){e.stopPropagation();}); waitForMap(); -})(); \ No newline at end of file +})(); \ No newline at end of file diff --git a/default-pages/western_cities_1850.json b/default-pages/western_cities_1850/page.json similarity index 91% rename from default-pages/western_cities_1850.json rename to default-pages/western_cities_1850/page.json index ba95a68..c9e3bff 100644 --- a/default-pages/western_cities_1850.json +++ b/default-pages/western_cities_1850/page.json @@ -7,6 +7,6 @@ "showInAll": false, "createdDate": "2026-02-17T19:25:22.510Z", "lastModified": "2026-02-17T19:25:22.510Z", - "pageVersion": 2, + "pageVersion": 3, "mode": "unlocked" -} \ No newline at end of file +} diff --git a/default-themes/aurora-dawn.json b/default-themes/aurora-dawn.json new file mode 100644 index 0000000..819cf9d --- /dev/null +++ b/default-themes/aurora-dawn.json @@ -0,0 +1,19 @@ +{ + "mode": "light", + "colors": { + "bg-primary": "#e9f4f6", + "bg-secondary": "#dceef0", + "bg-tertiary": "#f2f9fa", + "accent-primary": "#00b4d8", + "accent-secondary": "#008299", + "accent-tertiary": "#66d3e8", + "accent-glow": "rgba(0,180,216,0.15)", + "text-primary": "#18363c", + "text-secondary": "#40788a", + "border-color": "rgba(0,180,216,0.25)", + "header-min-height": "58px", + "header-padding-vertical": "14px", + "header-padding-horizontal": "20px", + "header-line-height": "1.25" + } +} diff --git a/default-themes/aurora-dawn.v3.css b/default-themes/aurora-dawn.v3.css new file mode 100644 index 0000000..34af549 --- /dev/null +++ b/default-themes/aurora-dawn.v3.css @@ -0,0 +1,198 @@ +/* ========================================================================== + Aurora Dawn — Light Theme (v3) + Palette overrides + semantic tokens for FluentLM. + Rebrands from default blue to Aurora teal/cyan. + ========================================================================== */ + +.aurora-dawn { + /* ----------------------------------------------------------------------- + PALETTE — Brand / Theme Colors (Aurora teal ramp) + ----------------------------------------------------------------------- */ + --themePrimary: #00b4d8; + --themeDark: #008299; + --themeDarkAlt: #009ab8; + --themeDarker: #004d5e; + --themeSecondary: #33c3e0; + --themeTertiary: #66d3e8; + --themeLight: #b3e8f3; + --themeLighter: #d9f3f9; + --themeLighterAlt: #ecf9fc; + + /* ----------------------------------------------------------------------- + PALETTE — Neutrals (cool teal-tinted) + ----------------------------------------------------------------------- */ + --neutralDark: #0e2a2e; + --neutralPrimary: #18363c; + --neutralPrimaryAlt: #1e4a52; + --neutralSecondary: #40788a; + --neutralSecondaryAlt: #366a79; + --neutralTertiary: #709da8; + --neutralTertiaryAlt: #8eb5be; + --neutralQuaternary: #bbd6db; + --neutralQuaternaryAlt: #cae0e4; + --neutralLight: #dceef0; + --neutralLighter: #e9f4f6; + --neutralLighterAlt: #f2f9fa; + --white: #ffffff; + + /* ----------------------------------------------------------------------- + BODY / BACKGROUND + ----------------------------------------------------------------------- */ + --bodyBackground: var(--white); + --bodyBackgroundHovered: var(--neutralLighter); + --bodyBackgroundChecked: var(--neutralLight); + --bodyStandoutBackground: var(--neutralLighterAlt); + --bodyFrameBackground: var(--white); + --bodyFrameDivider: var(--neutralLight); + --bodyDivider: var(--neutralLight); + + /* ----------------------------------------------------------------------- + TEXT + ----------------------------------------------------------------------- */ + --bodyText: var(--neutralPrimary); + --bodyTextChecked: var(--neutralDark); + --bodySubtext: var(--neutralSecondary); + --disabledText: var(--neutralTertiary); + --disabledBodyText: var(--neutralTertiary); + --disabledSubtext: var(--neutralQuaternary); + --disabledBodySubtext: var(--neutralTertiaryAlt); + + /* ----------------------------------------------------------------------- + LINKS + ----------------------------------------------------------------------- */ + --link: var(--themePrimary); + --linkHovered: var(--themeDarker); + --actionLink: var(--neutralPrimary); + --actionLinkHovered: var(--neutralDark); + + /* ----------------------------------------------------------------------- + BUTTONS — Default + ----------------------------------------------------------------------- */ + --buttonBackground: var(--white); + --buttonBackgroundHovered: var(--neutralLighter); + --buttonBackgroundChecked: var(--neutralLight); + --buttonBackgroundCheckedHovered: var(--neutralQuaternaryAlt); + --buttonBackgroundPressed: var(--neutralLight); + --buttonBackgroundDisabled: var(--neutralLighter); + --buttonBorder: var(--neutralSecondaryAlt); + --buttonBorderDisabled: var(--neutralLighter); + --buttonText: var(--neutralPrimary); + --buttonTextHovered: var(--neutralDark); + --buttonTextChecked: var(--neutralDark); + --buttonTextCheckedHovered: var(--black); + --buttonTextPressed: var(--neutralDark); + --buttonTextDisabled: var(--neutralTertiary); + + /* ----------------------------------------------------------------------- + BUTTONS — Primary + ----------------------------------------------------------------------- */ + --primaryButtonBackground: var(--themePrimary); + --primaryButtonBackgroundHovered: var(--themeDarkAlt); + --primaryButtonBackgroundPressed: var(--themeDark); + --primaryButtonBackgroundDisabled: var(--neutralLighter); + --primaryButtonBorder: transparent; + --primaryButtonText: var(--white); + --primaryButtonTextHovered: var(--white); + --primaryButtonTextPressed: var(--white); + --primaryButtonTextDisabled: var(--neutralQuaternary); + + /* ----------------------------------------------------------------------- + BUTTONS — Accent + ----------------------------------------------------------------------- */ + --accentButtonBackground: var(--themePrimary); + --accentButtonText: var(--white); + + /* ----------------------------------------------------------------------- + INPUTS + ----------------------------------------------------------------------- */ + --inputBackground: var(--white); + --inputBackgroundChecked: var(--themePrimary); + --inputBackgroundCheckedHovered: var(--themeDarkAlt); + --inputPlaceholderBackgroundChecked: var(--themeLighter); + --inputForegroundChecked: var(--white); + --inputBorder: var(--neutralSecondary); + --smallInputBorder: var(--neutralSecondary); + --inputBorderHovered: var(--neutralPrimary); + --inputFocusBorderAlt: var(--themePrimary); + --inputText: var(--neutralPrimary); + --inputTextHovered: var(--neutralDark); + --inputPlaceholderText: var(--neutralSecondary); + --inputIcon: var(--themePrimary); + --inputIconHovered: var(--themeDark); + --inputIconDisabled: var(--neutralTertiary); + + /* ----------------------------------------------------------------------- + LISTS + ----------------------------------------------------------------------- */ + --listBackground: var(--white); + --listText: var(--neutralPrimary); + --listItemBackgroundHovered: var(--neutralLighter); + --listItemBackgroundChecked: var(--neutralLight); + --listItemBackgroundCheckedHovered: var(--neutralQuaternaryAlt); + --listHeaderBackgroundHovered: var(--neutralLighter); + --listHeaderBackgroundPressed: var(--neutralLight); + + /* ----------------------------------------------------------------------- + MENUS + ----------------------------------------------------------------------- */ + --menuBackground: var(--white); + --menuDivider: var(--neutralTertiaryAlt); + --menuIcon: var(--themePrimary); + --menuHeader: var(--themePrimary); + --menuItemBackgroundHovered: var(--neutralLighter); + --menuItemBackgroundPressed: var(--neutralLight); + --menuItemText: var(--neutralPrimary); + --menuItemTextHovered: var(--neutralDark); + + /* ----------------------------------------------------------------------- + CARDS + ----------------------------------------------------------------------- */ + --cardStandoutBackground: var(--white); + --cardShadow: var(--elevation4); + --cardShadowHovered: var(--elevation8); + --variantBorder: var(--neutralLight); + --variantBorderHovered: var(--neutralTertiaryAlt); + + /* ----------------------------------------------------------------------- + DISABLED / FOCUS + ----------------------------------------------------------------------- */ + --disabledBackground: var(--neutralLighter); + --disabledBorder: var(--neutralLighter); + --focusBorder: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + STATUS — Backgrounds + ----------------------------------------------------------------------- */ + --infoBackground: var(--neutralLighter); + --errorBackground: #FDE7E9; + --blockingBackground: #FDE7E9; + --warningBackground: #FFF4CE; + --severeWarningBackground: #FED9CC; + --successBackground: #DFF6DD; + + /* ----------------------------------------------------------------------- + STATUS — Icons + ----------------------------------------------------------------------- */ + --infoIcon: var(--neutralSecondary); + --errorIcon: #A80000; + --blockingIcon: #FDE7E9; + --warningIcon: #797775; + --severeWarningIcon: var(--orange); + --successIcon: var(--green); + + /* ----------------------------------------------------------------------- + STATUS — Text + ----------------------------------------------------------------------- */ + --errorText: var(--redDark); + --messageText: var(--neutralPrimary); + --messageLink: var(--themeDark); + --messageLinkHovered: var(--themeDarker); + --warningText: var(--neutralPrimary); + --successText: var(--green); + + /* ----------------------------------------------------------------------- + MISC + ----------------------------------------------------------------------- */ + --defaultStateBackground: var(--neutralLighterAlt); + --overlayBackground: var(--blackTranslucent40); +} diff --git a/default-themes/aurora-dusk.json b/default-themes/aurora-dusk.json new file mode 100644 index 0000000..4988757 --- /dev/null +++ b/default-themes/aurora-dusk.json @@ -0,0 +1,19 @@ +{ + "mode": "dark", + "colors": { + "bg-primary": "#0e2a2e", + "bg-secondary": "#0c2028", + "bg-tertiary": "#061518", + "accent-primary": "#00b4d8", + "accent-secondary": "#008299", + "accent-tertiary": "#66d3e8", + "accent-glow": "rgba(0,180,216,0.3)", + "text-primary": "#d4e6ea", + "text-secondary": "#66d3e8", + "border-color": "rgba(0,180,216,0.3)", + "header-min-height": "58px", + "header-padding-vertical": "14px", + "header-padding-horizontal": "20px", + "header-line-height": "1.25" + } +} diff --git a/default-themes/aurora-dusk.v3.css b/default-themes/aurora-dusk.v3.css new file mode 100644 index 0000000..acb2765 --- /dev/null +++ b/default-themes/aurora-dusk.v3.css @@ -0,0 +1,200 @@ +/* ========================================================================== + Aurora Dusk — Dark Theme (v3) + Palette overrides + semantic tokens for FluentLM. + Rebrands from default blue to Aurora teal/cyan, dark mode. + ========================================================================== */ + +.aurora-dusk { + /* ----------------------------------------------------------------------- + PALETTE — Brand / Theme Colors (Aurora teal ramp) + ----------------------------------------------------------------------- */ + --themePrimary: #00b4d8; + --themeDark: #008299; + --themeDarkAlt: #009ab8; + --themeDarker: #004d5e; + --themeSecondary: #33c3e0; + --themeTertiary: #66d3e8; + --themeLight: #b3e8f3; + --themeLighter: #d9f3f9; + --themeLighterAlt: #ecf9fc; + + /* ----------------------------------------------------------------------- + PALETTE — Neutrals (inverted for dark mode, cool teal-tinted) + ----------------------------------------------------------------------- */ + --black: #000000; + --neutralDarker: #061518; + --neutralDark: #0c2028; + --neutralPrimary: #0e2a2e; + --neutralPrimaryAlt: #163a42; + --neutralSecondary: #1e4a52; + --neutralSecondaryAlt: #2a5e68; + --neutralTertiary: #40788a; + --neutralTertiaryAlt: #66d3e8; + --neutralQuaternary: #709da8; + --neutralQuaternaryAlt: #8eb5be; + --neutralLight: #a8c8ce; + --neutralLighter: #d4e6ea; + --neutralLighterAlt: #e9f4f6; + --white: #ffffff; + + /* ----------------------------------------------------------------------- + BODY / BACKGROUND + ----------------------------------------------------------------------- */ + --bodyBackground: var(--neutralPrimary); + --bodyBackgroundHovered: var(--neutralPrimaryAlt); + --bodyBackgroundChecked: var(--neutralSecondary); + --bodyStandoutBackground: var(--neutralDark); + --bodyFrameBackground: var(--neutralPrimary); + --bodyFrameDivider: var(--neutralSecondary); + --bodyDivider: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + TEXT + ----------------------------------------------------------------------- */ + --bodyText: var(--neutralLighter); + --bodyTextChecked: var(--white); + --bodySubtext: var(--neutralTertiaryAlt); + --disabledText: var(--neutralSecondary); + --disabledBodyText: var(--neutralSecondary); + --disabledSubtext: var(--neutralSecondaryAlt); + --disabledBodySubtext: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + LINKS + ----------------------------------------------------------------------- */ + --link: #66d3e8; + --linkHovered: #b3e8f3; + --actionLink: var(--neutralLighter); + --actionLinkHovered: var(--white); + + /* ----------------------------------------------------------------------- + BUTTONS — Default + ----------------------------------------------------------------------- */ + --buttonBackground: var(--neutralPrimary); + --buttonBackgroundHovered: var(--neutralPrimaryAlt); + --buttonBackgroundChecked: var(--neutralSecondary); + --buttonBackgroundCheckedHovered: var(--neutralSecondaryAlt); + --buttonBackgroundPressed: var(--neutralSecondary); + --buttonBackgroundDisabled: var(--neutralDark); + --buttonBorder: var(--neutralSecondaryAlt); + --buttonBorderDisabled: var(--neutralDark); + --buttonText: var(--neutralLighter); + --buttonTextHovered: var(--white); + --buttonTextChecked: var(--white); + --buttonTextCheckedHovered: var(--white); + --buttonTextPressed: var(--white); + --buttonTextDisabled: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + BUTTONS — Primary + ----------------------------------------------------------------------- */ + --primaryButtonBackground: var(--themePrimary); + --primaryButtonBackgroundHovered: var(--themeDarkAlt); + --primaryButtonBackgroundPressed: var(--themeDark); + --primaryButtonBackgroundDisabled: var(--neutralDark); + --primaryButtonBorder: transparent; + --primaryButtonText: var(--white); + --primaryButtonTextHovered: var(--white); + --primaryButtonTextPressed: var(--white); + --primaryButtonTextDisabled: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + BUTTONS — Accent + ----------------------------------------------------------------------- */ + --accentButtonBackground: var(--themePrimary); + --accentButtonText: var(--white); + + /* ----------------------------------------------------------------------- + INPUTS + ----------------------------------------------------------------------- */ + --inputBackground: var(--neutralPrimary); + --inputBackgroundChecked: var(--themePrimary); + --inputBackgroundCheckedHovered: var(--themeDarkAlt); + --inputPlaceholderBackgroundChecked: var(--themeDark); + --inputForegroundChecked: var(--white); + --inputBorder: var(--neutralTertiary); + --smallInputBorder: var(--neutralTertiary); + --inputBorderHovered: var(--neutralLighter); + --inputFocusBorderAlt: var(--themeSecondary); + --inputText: var(--neutralLighter); + --inputTextHovered: var(--white); + --inputPlaceholderText: var(--neutralTertiaryAlt); + --inputIcon: var(--themeSecondary); + --inputIconHovered: var(--themePrimary); + --inputIconDisabled: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + LISTS + ----------------------------------------------------------------------- */ + --listBackground: var(--neutralPrimary); + --listText: var(--neutralLighter); + --listItemBackgroundHovered: var(--neutralPrimaryAlt); + --listItemBackgroundChecked: var(--neutralSecondary); + --listItemBackgroundCheckedHovered: var(--neutralSecondaryAlt); + --listHeaderBackgroundHovered: var(--neutralPrimaryAlt); + --listHeaderBackgroundPressed: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + MENUS + ----------------------------------------------------------------------- */ + --menuBackground: var(--neutralPrimary); + --menuDivider: var(--neutralSecondary); + --menuIcon: var(--themeSecondary); + --menuHeader: var(--themeSecondary); + --menuItemBackgroundHovered: var(--neutralPrimaryAlt); + --menuItemBackgroundPressed: var(--neutralSecondary); + --menuItemText: var(--neutralLighter); + --menuItemTextHovered: var(--white); + + /* ----------------------------------------------------------------------- + CARDS + ----------------------------------------------------------------------- */ + --cardStandoutBackground: var(--neutralDark); + --cardShadow: none; + --cardShadowHovered: none; + --variantBorder: var(--neutralSecondary); + --variantBorderHovered: var(--neutralTertiary); + + /* ----------------------------------------------------------------------- + DISABLED / FOCUS + ----------------------------------------------------------------------- */ + --disabledBackground: var(--neutralDark); + --disabledBorder: var(--neutralDark); + --focusBorder: var(--neutralTertiaryAlt); + + /* ----------------------------------------------------------------------- + STATUS — Backgrounds + ----------------------------------------------------------------------- */ + --infoBackground: var(--neutralPrimary); + --errorBackground: #442726; + --blockingBackground: #442726; + --warningBackground: #433519; + --severeWarningBackground: #4F2A0F; + --successBackground: #393D1B; + + /* ----------------------------------------------------------------------- + STATUS — Icons + ----------------------------------------------------------------------- */ + --infoIcon: var(--neutralTertiaryAlt); + --errorIcon: #F1707B; + --blockingIcon: #442726; + --warningIcon: var(--neutralTertiaryAlt); + --severeWarningIcon: #FCE100; + --successIcon: #92C353; + + /* ----------------------------------------------------------------------- + STATUS — Text + ----------------------------------------------------------------------- */ + --errorText: #F1707B; + --messageText: var(--neutralLighter); + --messageLink: #66d3e8; + --messageLinkHovered: #b3e8f3; + --warningText: var(--neutralLighter); + --successText: #92C353; + + /* ----------------------------------------------------------------------- + MISC + ----------------------------------------------------------------------- */ + --defaultStateBackground: var(--neutralDark); + --overlayBackground: rgba(0, 0, 0, 0.6); +} diff --git a/default-themes/cosmos-dawn.json b/default-themes/cosmos-dawn.json new file mode 100644 index 0000000..e2b1d0c --- /dev/null +++ b/default-themes/cosmos-dawn.json @@ -0,0 +1,19 @@ +{ + "mode": "light", + "colors": { + "bg-primary": "#eeedf6", + "bg-secondary": "#e4e1f0", + "bg-tertiary": "#f5f4fa", + "accent-primary": "#3f37c9", + "accent-secondary": "#2e28a0", + "accent-tertiary": "#908cdf", + "accent-glow": "rgba(63,55,201,0.15)", + "text-primary": "#1e1a40", + "text-secondary": "#4e488a", + "border-color": "rgba(63,55,201,0.25)", + "header-min-height": "58px", + "header-padding-vertical": "14px", + "header-padding-horizontal": "20px", + "header-line-height": "1.25" + } +} diff --git a/default-themes/cosmos-dawn.v3.css b/default-themes/cosmos-dawn.v3.css new file mode 100644 index 0000000..457d5d3 --- /dev/null +++ b/default-themes/cosmos-dawn.v3.css @@ -0,0 +1,198 @@ +/* ========================================================================== + Cosmos Dawn — Light Theme (v3) + Palette overrides + semantic tokens for FluentLM. + Rebrands from default blue to Cosmos deep indigo. + ========================================================================== */ + +.cosmos-dawn { + /* ----------------------------------------------------------------------- + PALETTE — Brand / Theme Colors (Cosmos indigo ramp) + ----------------------------------------------------------------------- */ + --themePrimary: #3f37c9; + --themeDark: #2e28a0; + --themeDarkAlt: #3630b5; + --themeDarker: #1a1654; + --themeSecondary: #6560d4; + --themeTertiary: #908cdf; + --themeLight: #c3c1ed; + --themeLighter: #e0dff6; + --themeLighterAlt: #f0f0fb; + + /* ----------------------------------------------------------------------- + PALETTE — Neutrals (cool indigo-tinted) + ----------------------------------------------------------------------- */ + --neutralDark: #121030; + --neutralPrimary: #1e1a40; + --neutralPrimaryAlt: #2a2556; + --neutralSecondary: #4e488a; + --neutralSecondaryAlt: #423c79; + --neutralTertiary: #7a74a0; + --neutralTertiaryAlt: #9994b8; + --neutralQuaternary: #c4c0d8; + --neutralQuaternaryAlt: #d2cee2; + --neutralLight: #e4e1f0; + --neutralLighter: #eeedf6; + --neutralLighterAlt: #f5f4fa; + --white: #ffffff; + + /* ----------------------------------------------------------------------- + BODY / BACKGROUND + ----------------------------------------------------------------------- */ + --bodyBackground: var(--white); + --bodyBackgroundHovered: var(--neutralLighter); + --bodyBackgroundChecked: var(--neutralLight); + --bodyStandoutBackground: var(--neutralLighterAlt); + --bodyFrameBackground: var(--white); + --bodyFrameDivider: var(--neutralLight); + --bodyDivider: var(--neutralLight); + + /* ----------------------------------------------------------------------- + TEXT + ----------------------------------------------------------------------- */ + --bodyText: var(--neutralPrimary); + --bodyTextChecked: var(--neutralDark); + --bodySubtext: var(--neutralSecondary); + --disabledText: var(--neutralTertiary); + --disabledBodyText: var(--neutralTertiary); + --disabledSubtext: var(--neutralQuaternary); + --disabledBodySubtext: var(--neutralTertiaryAlt); + + /* ----------------------------------------------------------------------- + LINKS + ----------------------------------------------------------------------- */ + --link: var(--themePrimary); + --linkHovered: var(--themeDarker); + --actionLink: var(--neutralPrimary); + --actionLinkHovered: var(--neutralDark); + + /* ----------------------------------------------------------------------- + BUTTONS — Default + ----------------------------------------------------------------------- */ + --buttonBackground: var(--white); + --buttonBackgroundHovered: var(--neutralLighter); + --buttonBackgroundChecked: var(--neutralLight); + --buttonBackgroundCheckedHovered: var(--neutralQuaternaryAlt); + --buttonBackgroundPressed: var(--neutralLight); + --buttonBackgroundDisabled: var(--neutralLighter); + --buttonBorder: var(--neutralSecondaryAlt); + --buttonBorderDisabled: var(--neutralLighter); + --buttonText: var(--neutralPrimary); + --buttonTextHovered: var(--neutralDark); + --buttonTextChecked: var(--neutralDark); + --buttonTextCheckedHovered: var(--black); + --buttonTextPressed: var(--neutralDark); + --buttonTextDisabled: var(--neutralTertiary); + + /* ----------------------------------------------------------------------- + BUTTONS — Primary + ----------------------------------------------------------------------- */ + --primaryButtonBackground: var(--themePrimary); + --primaryButtonBackgroundHovered: var(--themeDarkAlt); + --primaryButtonBackgroundPressed: var(--themeDark); + --primaryButtonBackgroundDisabled: var(--neutralLighter); + --primaryButtonBorder: transparent; + --primaryButtonText: var(--white); + --primaryButtonTextHovered: var(--white); + --primaryButtonTextPressed: var(--white); + --primaryButtonTextDisabled: var(--neutralQuaternary); + + /* ----------------------------------------------------------------------- + BUTTONS — Accent + ----------------------------------------------------------------------- */ + --accentButtonBackground: var(--themePrimary); + --accentButtonText: var(--white); + + /* ----------------------------------------------------------------------- + INPUTS + ----------------------------------------------------------------------- */ + --inputBackground: var(--white); + --inputBackgroundChecked: var(--themePrimary); + --inputBackgroundCheckedHovered: var(--themeDarkAlt); + --inputPlaceholderBackgroundChecked: var(--themeLighter); + --inputForegroundChecked: var(--white); + --inputBorder: var(--neutralSecondary); + --smallInputBorder: var(--neutralSecondary); + --inputBorderHovered: var(--neutralPrimary); + --inputFocusBorderAlt: var(--themePrimary); + --inputText: var(--neutralPrimary); + --inputTextHovered: var(--neutralDark); + --inputPlaceholderText: var(--neutralSecondary); + --inputIcon: var(--themePrimary); + --inputIconHovered: var(--themeDark); + --inputIconDisabled: var(--neutralTertiary); + + /* ----------------------------------------------------------------------- + LISTS + ----------------------------------------------------------------------- */ + --listBackground: var(--white); + --listText: var(--neutralPrimary); + --listItemBackgroundHovered: var(--neutralLighter); + --listItemBackgroundChecked: var(--neutralLight); + --listItemBackgroundCheckedHovered: var(--neutralQuaternaryAlt); + --listHeaderBackgroundHovered: var(--neutralLighter); + --listHeaderBackgroundPressed: var(--neutralLight); + + /* ----------------------------------------------------------------------- + MENUS + ----------------------------------------------------------------------- */ + --menuBackground: var(--white); + --menuDivider: var(--neutralTertiaryAlt); + --menuIcon: var(--themePrimary); + --menuHeader: var(--themePrimary); + --menuItemBackgroundHovered: var(--neutralLighter); + --menuItemBackgroundPressed: var(--neutralLight); + --menuItemText: var(--neutralPrimary); + --menuItemTextHovered: var(--neutralDark); + + /* ----------------------------------------------------------------------- + CARDS + ----------------------------------------------------------------------- */ + --cardStandoutBackground: var(--white); + --cardShadow: var(--elevation4); + --cardShadowHovered: var(--elevation8); + --variantBorder: var(--neutralLight); + --variantBorderHovered: var(--neutralTertiaryAlt); + + /* ----------------------------------------------------------------------- + DISABLED / FOCUS + ----------------------------------------------------------------------- */ + --disabledBackground: var(--neutralLighter); + --disabledBorder: var(--neutralLighter); + --focusBorder: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + STATUS — Backgrounds + ----------------------------------------------------------------------- */ + --infoBackground: var(--neutralLighter); + --errorBackground: #FDE7E9; + --blockingBackground: #FDE7E9; + --warningBackground: #FFF4CE; + --severeWarningBackground: #FED9CC; + --successBackground: #DFF6DD; + + /* ----------------------------------------------------------------------- + STATUS — Icons + ----------------------------------------------------------------------- */ + --infoIcon: var(--neutralSecondary); + --errorIcon: #A80000; + --blockingIcon: #FDE7E9; + --warningIcon: #797775; + --severeWarningIcon: var(--orange); + --successIcon: var(--green); + + /* ----------------------------------------------------------------------- + STATUS — Text + ----------------------------------------------------------------------- */ + --errorText: var(--redDark); + --messageText: var(--neutralPrimary); + --messageLink: var(--themeDark); + --messageLinkHovered: var(--themeDarker); + --warningText: var(--neutralPrimary); + --successText: var(--green); + + /* ----------------------------------------------------------------------- + MISC + ----------------------------------------------------------------------- */ + --defaultStateBackground: var(--neutralLighterAlt); + --overlayBackground: var(--blackTranslucent40); +} diff --git a/default-themes/cosmos-dusk.json b/default-themes/cosmos-dusk.json new file mode 100644 index 0000000..ed00311 --- /dev/null +++ b/default-themes/cosmos-dusk.json @@ -0,0 +1,19 @@ +{ + "mode": "dark", + "colors": { + "bg-primary": "#121030", + "bg-secondary": "#0f0d28", + "bg-tertiary": "#08071a", + "accent-primary": "#3f37c9", + "accent-secondary": "#2e28a0", + "accent-tertiary": "#908cdf", + "accent-glow": "rgba(63,55,201,0.3)", + "text-primary": "#d4d0e2", + "text-secondary": "#908cdf", + "border-color": "rgba(63,55,201,0.3)", + "header-min-height": "58px", + "header-padding-vertical": "14px", + "header-padding-horizontal": "20px", + "header-line-height": "1.25" + } +} diff --git a/default-themes/cosmos-dusk.v3.css b/default-themes/cosmos-dusk.v3.css new file mode 100644 index 0000000..40d44c8 --- /dev/null +++ b/default-themes/cosmos-dusk.v3.css @@ -0,0 +1,200 @@ +/* ========================================================================== + Cosmos Dusk — Dark Theme (v3) + Palette overrides + semantic tokens for FluentLM. + Rebrands from default blue to Cosmos deep indigo, dark mode. + ========================================================================== */ + +.cosmos-dusk { + /* ----------------------------------------------------------------------- + PALETTE — Brand / Theme Colors (Cosmos indigo ramp) + ----------------------------------------------------------------------- */ + --themePrimary: #3f37c9; + --themeDark: #2e28a0; + --themeDarkAlt: #3630b5; + --themeDarker: #1a1654; + --themeSecondary: #6560d4; + --themeTertiary: #908cdf; + --themeLight: #c3c1ed; + --themeLighter: #e0dff6; + --themeLighterAlt: #f0f0fb; + + /* ----------------------------------------------------------------------- + PALETTE — Neutrals (inverted for dark mode, cool indigo-tinted) + ----------------------------------------------------------------------- */ + --black: #000000; + --neutralDarker: #08071a; + --neutralDark: #0f0d28; + --neutralPrimary: #121030; + --neutralPrimaryAlt: #1e1a44; + --neutralSecondary: #2a2556; + --neutralSecondaryAlt: #3a3468; + --neutralTertiary: #5a5480; + --neutralTertiaryAlt: #908cdf; + --neutralQuaternary: #7a74a0; + --neutralQuaternaryAlt: #9994b8; + --neutralLight: #b0aac8; + --neutralLighter: #d4d0e2; + --neutralLighterAlt: #eeedf6; + --white: #ffffff; + + /* ----------------------------------------------------------------------- + BODY / BACKGROUND + ----------------------------------------------------------------------- */ + --bodyBackground: var(--neutralPrimary); + --bodyBackgroundHovered: var(--neutralPrimaryAlt); + --bodyBackgroundChecked: var(--neutralSecondary); + --bodyStandoutBackground: var(--neutralDark); + --bodyFrameBackground: var(--neutralPrimary); + --bodyFrameDivider: var(--neutralSecondary); + --bodyDivider: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + TEXT + ----------------------------------------------------------------------- */ + --bodyText: var(--neutralLighter); + --bodyTextChecked: var(--white); + --bodySubtext: var(--neutralTertiaryAlt); + --disabledText: var(--neutralSecondary); + --disabledBodyText: var(--neutralSecondary); + --disabledSubtext: var(--neutralSecondaryAlt); + --disabledBodySubtext: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + LINKS + ----------------------------------------------------------------------- */ + --link: #908cdf; + --linkHovered: #c3c1ed; + --actionLink: var(--neutralLighter); + --actionLinkHovered: var(--white); + + /* ----------------------------------------------------------------------- + BUTTONS — Default + ----------------------------------------------------------------------- */ + --buttonBackground: var(--neutralPrimary); + --buttonBackgroundHovered: var(--neutralPrimaryAlt); + --buttonBackgroundChecked: var(--neutralSecondary); + --buttonBackgroundCheckedHovered: var(--neutralSecondaryAlt); + --buttonBackgroundPressed: var(--neutralSecondary); + --buttonBackgroundDisabled: var(--neutralDark); + --buttonBorder: var(--neutralSecondaryAlt); + --buttonBorderDisabled: var(--neutralDark); + --buttonText: var(--neutralLighter); + --buttonTextHovered: var(--white); + --buttonTextChecked: var(--white); + --buttonTextCheckedHovered: var(--white); + --buttonTextPressed: var(--white); + --buttonTextDisabled: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + BUTTONS — Primary + ----------------------------------------------------------------------- */ + --primaryButtonBackground: var(--themePrimary); + --primaryButtonBackgroundHovered: var(--themeDarkAlt); + --primaryButtonBackgroundPressed: var(--themeDark); + --primaryButtonBackgroundDisabled: var(--neutralDark); + --primaryButtonBorder: transparent; + --primaryButtonText: var(--white); + --primaryButtonTextHovered: var(--white); + --primaryButtonTextPressed: var(--white); + --primaryButtonTextDisabled: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + BUTTONS — Accent + ----------------------------------------------------------------------- */ + --accentButtonBackground: var(--themePrimary); + --accentButtonText: var(--white); + + /* ----------------------------------------------------------------------- + INPUTS + ----------------------------------------------------------------------- */ + --inputBackground: var(--neutralPrimary); + --inputBackgroundChecked: var(--themePrimary); + --inputBackgroundCheckedHovered: var(--themeDarkAlt); + --inputPlaceholderBackgroundChecked: var(--themeDark); + --inputForegroundChecked: var(--white); + --inputBorder: var(--neutralTertiary); + --smallInputBorder: var(--neutralTertiary); + --inputBorderHovered: var(--neutralLighter); + --inputFocusBorderAlt: var(--themeSecondary); + --inputText: var(--neutralLighter); + --inputTextHovered: var(--white); + --inputPlaceholderText: var(--neutralTertiaryAlt); + --inputIcon: var(--themeSecondary); + --inputIconHovered: var(--themePrimary); + --inputIconDisabled: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + LISTS + ----------------------------------------------------------------------- */ + --listBackground: var(--neutralPrimary); + --listText: var(--neutralLighter); + --listItemBackgroundHovered: var(--neutralPrimaryAlt); + --listItemBackgroundChecked: var(--neutralSecondary); + --listItemBackgroundCheckedHovered: var(--neutralSecondaryAlt); + --listHeaderBackgroundHovered: var(--neutralPrimaryAlt); + --listHeaderBackgroundPressed: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + MENUS + ----------------------------------------------------------------------- */ + --menuBackground: var(--neutralPrimary); + --menuDivider: var(--neutralSecondary); + --menuIcon: var(--themeSecondary); + --menuHeader: var(--themeSecondary); + --menuItemBackgroundHovered: var(--neutralPrimaryAlt); + --menuItemBackgroundPressed: var(--neutralSecondary); + --menuItemText: var(--neutralLighter); + --menuItemTextHovered: var(--white); + + /* ----------------------------------------------------------------------- + CARDS + ----------------------------------------------------------------------- */ + --cardStandoutBackground: var(--neutralDark); + --cardShadow: none; + --cardShadowHovered: none; + --variantBorder: var(--neutralSecondary); + --variantBorderHovered: var(--neutralTertiary); + + /* ----------------------------------------------------------------------- + DISABLED / FOCUS + ----------------------------------------------------------------------- */ + --disabledBackground: var(--neutralDark); + --disabledBorder: var(--neutralDark); + --focusBorder: var(--neutralTertiaryAlt); + + /* ----------------------------------------------------------------------- + STATUS — Backgrounds + ----------------------------------------------------------------------- */ + --infoBackground: var(--neutralPrimary); + --errorBackground: #442726; + --blockingBackground: #442726; + --warningBackground: #433519; + --severeWarningBackground: #4F2A0F; + --successBackground: #393D1B; + + /* ----------------------------------------------------------------------- + STATUS — Icons + ----------------------------------------------------------------------- */ + --infoIcon: var(--neutralTertiaryAlt); + --errorIcon: #F1707B; + --blockingIcon: #442726; + --warningIcon: var(--neutralTertiaryAlt); + --severeWarningIcon: #FCE100; + --successIcon: #92C353; + + /* ----------------------------------------------------------------------- + STATUS — Text + ----------------------------------------------------------------------- */ + --errorText: #F1707B; + --messageText: var(--neutralLighter); + --messageLink: #908cdf; + --messageLinkHovered: #c3c1ed; + --warningText: var(--neutralLighter); + --successText: #92C353; + + /* ----------------------------------------------------------------------- + MISC + ----------------------------------------------------------------------- */ + --defaultStateBackground: var(--neutralDark); + --overlayBackground: rgba(0, 0, 0, 0.6); +} diff --git a/default-themes/high-contrast-dark.json b/default-themes/high-contrast-dark.json new file mode 100644 index 0000000..3eb54a6 --- /dev/null +++ b/default-themes/high-contrast-dark.json @@ -0,0 +1,19 @@ +{ + "mode": "dark", + "colors": { + "bg-primary": "#000000", + "bg-secondary": "#0a0a0a", + "bg-tertiary": "#000000", + "accent-primary": "#ffffff", + "accent-secondary": "#ffffff", + "accent-tertiary": "#999999", + "accent-glow": "rgba(255,255,255,0.15)", + "text-primary": "#ffffff", + "text-secondary": "#e0e0e0", + "border-color": "#ffffff", + "header-min-height": "58px", + "header-padding-vertical": "14px", + "header-padding-horizontal": "20px", + "header-line-height": "1.25" + } +} diff --git a/default-themes/high-contrast-dark.v3.css b/default-themes/high-contrast-dark.v3.css new file mode 100644 index 0000000..388d923 --- /dev/null +++ b/default-themes/high-contrast-dark.v3.css @@ -0,0 +1,200 @@ +/* ========================================================================== + High Contrast Dark — Dark Theme (v3) + Maximum contrast for accessibility (WCAG AAA 7:1+). + White primary on black body, yellow links. + ========================================================================== */ + +.high-contrast-dark { + /* ----------------------------------------------------------------------- + PALETTE — Brand / Theme Colors (pure white ramp) + ----------------------------------------------------------------------- */ + --themePrimary: #ffffff; + --themeDark: #ffffff; + --themeDarkAlt: #ffffff; + --themeDarker: #ffffff; + --themeSecondary: #cccccc; + --themeTertiary: #999999; + --themeLight: #333333; + --themeLighter: #1a1a1a; + --themeLighterAlt: #0d0d0d; + + /* ----------------------------------------------------------------------- + PALETTE — Neutrals (inverted for dark mode, pure grayscale) + ----------------------------------------------------------------------- */ + --black: #000000; + --neutralDarker: #000000; + --neutralDark: #0a0a0a; + --neutralPrimary: #000000; + --neutralPrimaryAlt: #141414; + --neutralSecondary: #1e1e1e; + --neutralSecondaryAlt: #2e2e2e; + --neutralTertiary: #555555; + --neutralTertiaryAlt: #ffffff; + --neutralQuaternary: #767676; + --neutralQuaternaryAlt: #999999; + --neutralLight: #c8c8c8; + --neutralLighter: #e0e0e0; + --neutralLighterAlt: #f0f0f0; + --white: #ffffff; + + /* ----------------------------------------------------------------------- + BODY / BACKGROUND + ----------------------------------------------------------------------- */ + --bodyBackground: #000000; + --bodyBackgroundHovered: var(--neutralPrimaryAlt); + --bodyBackgroundChecked: var(--neutralSecondary); + --bodyStandoutBackground: #000000; + --bodyFrameBackground: #000000; + --bodyFrameDivider: #ffffff; + --bodyDivider: #ffffff; + + /* ----------------------------------------------------------------------- + TEXT + ----------------------------------------------------------------------- */ + --bodyText: #ffffff; + --bodyTextChecked: #ffffff; + --bodySubtext: #e0e0e0; + --disabledText: #767676; + --disabledBodyText: #767676; + --disabledSubtext: #555555; + --disabledBodySubtext: #555555; + + /* ----------------------------------------------------------------------- + LINKS + ----------------------------------------------------------------------- */ + --link: #ffff00; + --linkHovered: #ffff99; + --actionLink: #ffffff; + --actionLinkHovered: #ffffff; + + /* ----------------------------------------------------------------------- + BUTTONS — Default + ----------------------------------------------------------------------- */ + --buttonBackground: #000000; + --buttonBackgroundHovered: var(--neutralPrimaryAlt); + --buttonBackgroundChecked: var(--neutralSecondary); + --buttonBackgroundCheckedHovered: var(--neutralSecondaryAlt); + --buttonBackgroundPressed: var(--neutralSecondary); + --buttonBackgroundDisabled: #000000; + --buttonBorder: #ffffff; + --buttonBorderDisabled: #767676; + --buttonText: #ffffff; + --buttonTextHovered: #ffffff; + --buttonTextChecked: #ffffff; + --buttonTextCheckedHovered: #ffffff; + --buttonTextPressed: #ffffff; + --buttonTextDisabled: #767676; + + /* ----------------------------------------------------------------------- + BUTTONS — Primary + ----------------------------------------------------------------------- */ + --primaryButtonBackground: #ffffff; + --primaryButtonBackgroundHovered: #e0e0e0; + --primaryButtonBackgroundPressed: #c8c8c8; + --primaryButtonBackgroundDisabled: #555555; + --primaryButtonBorder: transparent; + --primaryButtonText: #000000; + --primaryButtonTextHovered: #000000; + --primaryButtonTextPressed: #000000; + --primaryButtonTextDisabled: #767676; + + /* ----------------------------------------------------------------------- + BUTTONS — Accent + ----------------------------------------------------------------------- */ + --accentButtonBackground: #ffffff; + --accentButtonText: #000000; + + /* ----------------------------------------------------------------------- + INPUTS + ----------------------------------------------------------------------- */ + --inputBackground: #000000; + --inputBackgroundChecked: #ffffff; + --inputBackgroundCheckedHovered: #e0e0e0; + --inputPlaceholderBackgroundChecked: #1a1a1a; + --inputForegroundChecked: #000000; + --inputBorder: #ffffff; + --smallInputBorder: #ffffff; + --inputBorderHovered: #ffffff; + --inputFocusBorderAlt: #ffff00; + --inputText: #ffffff; + --inputTextHovered: #ffffff; + --inputPlaceholderText: #c8c8c8; + --inputIcon: #ffffff; + --inputIconHovered: #ffff00; + --inputIconDisabled: #767676; + + /* ----------------------------------------------------------------------- + LISTS + ----------------------------------------------------------------------- */ + --listBackground: #000000; + --listText: #ffffff; + --listItemBackgroundHovered: var(--neutralPrimaryAlt); + --listItemBackgroundChecked: var(--neutralSecondary); + --listItemBackgroundCheckedHovered: var(--neutralSecondaryAlt); + --listHeaderBackgroundHovered: var(--neutralPrimaryAlt); + --listHeaderBackgroundPressed: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + MENUS + ----------------------------------------------------------------------- */ + --menuBackground: #000000; + --menuDivider: #ffffff; + --menuIcon: #ffffff; + --menuHeader: #ffffff; + --menuItemBackgroundHovered: var(--neutralPrimaryAlt); + --menuItemBackgroundPressed: var(--neutralSecondary); + --menuItemText: #ffffff; + --menuItemTextHovered: #ffffff; + + /* ----------------------------------------------------------------------- + CARDS + ----------------------------------------------------------------------- */ + --cardStandoutBackground: #000000; + --cardShadow: none; + --cardShadowHovered: none; + --variantBorder: #ffffff; + --variantBorderHovered: #ffffff; + + /* ----------------------------------------------------------------------- + DISABLED / FOCUS + ----------------------------------------------------------------------- */ + --disabledBackground: #1a1a1a; + --disabledBorder: #767676; + --focusBorder: #ffffff; + + /* ----------------------------------------------------------------------- + STATUS — Backgrounds + ----------------------------------------------------------------------- */ + --infoBackground: #1a1a1a; + --errorBackground: #440000; + --blockingBackground: #440000; + --warningBackground: #443300; + --severeWarningBackground: #442200; + --successBackground: #003300; + + /* ----------------------------------------------------------------------- + STATUS — Icons + ----------------------------------------------------------------------- */ + --infoIcon: #ffffff; + --errorIcon: #ff6666; + --blockingIcon: #ff6666; + --warningIcon: #ffff00; + --severeWarningIcon: #ff6600; + --successIcon: #66ff66; + + /* ----------------------------------------------------------------------- + STATUS — Text + ----------------------------------------------------------------------- */ + --errorText: #ff6666; + --messageText: #ffffff; + --messageLink: #ffff00; + --messageLinkHovered: #ffff99; + --warningText: #ffffff; + --successText: #66ff66; + + /* ----------------------------------------------------------------------- + MISC + ----------------------------------------------------------------------- */ + --defaultStateBackground: #0a0a0a; + --overlayBackground: rgba(0, 0, 0, 0.8); +} diff --git a/default-themes/high-contrast-light.json b/default-themes/high-contrast-light.json new file mode 100644 index 0000000..a3a9795 --- /dev/null +++ b/default-themes/high-contrast-light.json @@ -0,0 +1,19 @@ +{ + "mode": "light", + "colors": { + "bg-primary": "#f0f0f0", + "bg-secondary": "#e0e0e0", + "bg-tertiary": "#f8f8f8", + "accent-primary": "#000000", + "accent-secondary": "#000000", + "accent-tertiary": "#666666", + "accent-glow": "rgba(0,0,0,0.15)", + "text-primary": "#000000", + "text-secondary": "#1a1a1a", + "border-color": "#000000", + "header-min-height": "58px", + "header-padding-vertical": "14px", + "header-padding-horizontal": "20px", + "header-line-height": "1.25" + } +} diff --git a/default-themes/high-contrast-light.v3.css b/default-themes/high-contrast-light.v3.css new file mode 100644 index 0000000..3e5226d --- /dev/null +++ b/default-themes/high-contrast-light.v3.css @@ -0,0 +1,198 @@ +/* ========================================================================== + High Contrast Light — Light Theme (v3) + Maximum contrast for accessibility (WCAG AAA 7:1+). + Pure grayscale neutrals, black primary. + ========================================================================== */ + +.high-contrast-light { + /* ----------------------------------------------------------------------- + PALETTE — Brand / Theme Colors (pure black ramp) + ----------------------------------------------------------------------- */ + --themePrimary: #000000; + --themeDark: #000000; + --themeDarkAlt: #000000; + --themeDarker: #000000; + --themeSecondary: #333333; + --themeTertiary: #666666; + --themeLight: #cccccc; + --themeLighter: #e6e6e6; + --themeLighterAlt: #f5f5f5; + + /* ----------------------------------------------------------------------- + PALETTE — Neutrals (pure untinted grayscale) + ----------------------------------------------------------------------- */ + --neutralDark: #000000; + --neutralPrimary: #1a1a1a; + --neutralPrimaryAlt: #2e2e2e; + --neutralSecondary: #555555; + --neutralSecondaryAlt: #444444; + --neutralTertiary: #767676; + --neutralTertiaryAlt: #999999; + --neutralQuaternary: #c8c8c8; + --neutralQuaternaryAlt: #d0d0d0; + --neutralLight: #e0e0e0; + --neutralLighter: #f0f0f0; + --neutralLighterAlt: #f8f8f8; + --white: #ffffff; + + /* ----------------------------------------------------------------------- + BODY / BACKGROUND + ----------------------------------------------------------------------- */ + --bodyBackground: var(--white); + --bodyBackgroundHovered: var(--neutralLighter); + --bodyBackgroundChecked: var(--neutralLight); + --bodyStandoutBackground: var(--neutralLighterAlt); + --bodyFrameBackground: var(--white); + --bodyFrameDivider: #000000; + --bodyDivider: #000000; + + /* ----------------------------------------------------------------------- + TEXT + ----------------------------------------------------------------------- */ + --bodyText: #000000; + --bodyTextChecked: #000000; + --bodySubtext: #1a1a1a; + --disabledText: #767676; + --disabledBodyText: #767676; + --disabledSubtext: #999999; + --disabledBodySubtext: #999999; + + /* ----------------------------------------------------------------------- + LINKS + ----------------------------------------------------------------------- */ + --link: #0000cc; + --linkHovered: #000066; + --actionLink: #000000; + --actionLinkHovered: #000000; + + /* ----------------------------------------------------------------------- + BUTTONS — Default + ----------------------------------------------------------------------- */ + --buttonBackground: var(--white); + --buttonBackgroundHovered: var(--neutralLighter); + --buttonBackgroundChecked: var(--neutralLight); + --buttonBackgroundCheckedHovered: var(--neutralQuaternaryAlt); + --buttonBackgroundPressed: var(--neutralLight); + --buttonBackgroundDisabled: var(--neutralLighter); + --buttonBorder: #000000; + --buttonBorderDisabled: #767676; + --buttonText: #000000; + --buttonTextHovered: #000000; + --buttonTextChecked: #000000; + --buttonTextCheckedHovered: #000000; + --buttonTextPressed: #000000; + --buttonTextDisabled: #767676; + + /* ----------------------------------------------------------------------- + BUTTONS — Primary + ----------------------------------------------------------------------- */ + --primaryButtonBackground: #000000; + --primaryButtonBackgroundHovered: #1a1a1a; + --primaryButtonBackgroundPressed: #333333; + --primaryButtonBackgroundDisabled: #767676; + --primaryButtonBorder: transparent; + --primaryButtonText: var(--white); + --primaryButtonTextHovered: var(--white); + --primaryButtonTextPressed: var(--white); + --primaryButtonTextDisabled: var(--neutralQuaternary); + + /* ----------------------------------------------------------------------- + BUTTONS — Accent + ----------------------------------------------------------------------- */ + --accentButtonBackground: #000000; + --accentButtonText: var(--white); + + /* ----------------------------------------------------------------------- + INPUTS + ----------------------------------------------------------------------- */ + --inputBackground: var(--white); + --inputBackgroundChecked: #000000; + --inputBackgroundCheckedHovered: #1a1a1a; + --inputPlaceholderBackgroundChecked: var(--neutralLighter); + --inputForegroundChecked: var(--white); + --inputBorder: #000000; + --smallInputBorder: #000000; + --inputBorderHovered: #000000; + --inputFocusBorderAlt: #000000; + --inputText: #000000; + --inputTextHovered: #000000; + --inputPlaceholderText: #555555; + --inputIcon: #000000; + --inputIconHovered: #000000; + --inputIconDisabled: #767676; + + /* ----------------------------------------------------------------------- + LISTS + ----------------------------------------------------------------------- */ + --listBackground: var(--white); + --listText: #000000; + --listItemBackgroundHovered: var(--neutralLighter); + --listItemBackgroundChecked: var(--neutralLight); + --listItemBackgroundCheckedHovered: var(--neutralQuaternaryAlt); + --listHeaderBackgroundHovered: var(--neutralLighter); + --listHeaderBackgroundPressed: var(--neutralLight); + + /* ----------------------------------------------------------------------- + MENUS + ----------------------------------------------------------------------- */ + --menuBackground: var(--white); + --menuDivider: #000000; + --menuIcon: #000000; + --menuHeader: #000000; + --menuItemBackgroundHovered: var(--neutralLighter); + --menuItemBackgroundPressed: var(--neutralLight); + --menuItemText: #000000; + --menuItemTextHovered: #000000; + + /* ----------------------------------------------------------------------- + CARDS + ----------------------------------------------------------------------- */ + --cardStandoutBackground: var(--white); + --cardShadow: none; + --cardShadowHovered: none; + --variantBorder: #000000; + --variantBorderHovered: #000000; + + /* ----------------------------------------------------------------------- + DISABLED / FOCUS + ----------------------------------------------------------------------- */ + --disabledBackground: var(--neutralLighter); + --disabledBorder: #767676; + --focusBorder: #000000; + + /* ----------------------------------------------------------------------- + STATUS — Backgrounds + ----------------------------------------------------------------------- */ + --infoBackground: var(--neutralLighter); + --errorBackground: #FDE7E9; + --blockingBackground: #FDE7E9; + --warningBackground: #FFF4CE; + --severeWarningBackground: #FED9CC; + --successBackground: #DFF6DD; + + /* ----------------------------------------------------------------------- + STATUS — Icons + ----------------------------------------------------------------------- */ + --infoIcon: #000000; + --errorIcon: #A80000; + --blockingIcon: #A80000; + --warningIcon: #333333; + --severeWarningIcon: #A80000; + --successIcon: #107C10; + + /* ----------------------------------------------------------------------- + STATUS — Text + ----------------------------------------------------------------------- */ + --errorText: #A80000; + --messageText: #000000; + --messageLink: #0000cc; + --messageLinkHovered: #000066; + --warningText: #000000; + --successText: #107C10; + + /* ----------------------------------------------------------------------- + MISC + ----------------------------------------------------------------------- */ + --defaultStateBackground: var(--neutralLighterAlt); + --overlayBackground: rgba(0, 0, 0, 0.5); +} diff --git a/default-themes/nebula-dawn.v3.css b/default-themes/nebula-dawn.v3.css new file mode 100644 index 0000000..a4004bd --- /dev/null +++ b/default-themes/nebula-dawn.v3.css @@ -0,0 +1,199 @@ +/* ========================================================================== + Nebula Dawn — Light Theme (v3) + Palette overrides + semantic tokens for FluentLM. + Rebrands from default blue to Nebula purple. + ========================================================================== */ + +:root, +.nebula-dawn { + /* ----------------------------------------------------------------------- + PALETTE — Brand / Theme Colors (Nebula purple ramp) + ----------------------------------------------------------------------- */ + --themePrimary: #764ba2; + --themeDark: #5a3580; + --themeDarkAlt: #663d91; + --themeDarker: #3b2054; + --themeSecondary: #9670b8; + --themeTertiary: #b49bce; + --themeLight: #d3c3e3; + --themeLighter: #e8ddf1; + --themeLighterAlt: #f5f1fa; + + /* ----------------------------------------------------------------------- + PALETTE — Neutrals (warm purple-tinted) + ----------------------------------------------------------------------- */ + --neutralDark: #1e1830; + --neutralPrimary: #2d2640; + --neutralPrimaryAlt: #3d2e56; + --neutralSecondary: #6b4f8a; + --neutralSecondaryAlt: #5e4579; + --neutralTertiary: #8a7a9d; + --neutralTertiaryAlt: #a898b5; + --neutralQuaternary: #cbc0d6; + --neutralQuaternaryAlt: #d5cae0; + --neutralLight: #e8dff0; + --neutralLighter: #f0e6f6; + --neutralLighterAlt: #f7f2fa; + --white: #ffffff; + + /* ----------------------------------------------------------------------- + BODY / BACKGROUND + ----------------------------------------------------------------------- */ + --bodyBackground: var(--white); + --bodyBackgroundHovered: var(--neutralLighter); + --bodyBackgroundChecked: var(--neutralLight); + --bodyStandoutBackground: var(--neutralLighterAlt); + --bodyFrameBackground: var(--white); + --bodyFrameDivider: var(--neutralLight); + --bodyDivider: var(--neutralLight); + + /* ----------------------------------------------------------------------- + TEXT + ----------------------------------------------------------------------- */ + --bodyText: var(--neutralPrimary); + --bodyTextChecked: var(--neutralDark); + --bodySubtext: var(--neutralSecondary); + --disabledText: var(--neutralTertiary); + --disabledBodyText: var(--neutralTertiary); + --disabledSubtext: var(--neutralQuaternary); + --disabledBodySubtext: var(--neutralTertiaryAlt); + + /* ----------------------------------------------------------------------- + LINKS + ----------------------------------------------------------------------- */ + --link: var(--themePrimary); + --linkHovered: var(--themeDarker); + --actionLink: var(--neutralPrimary); + --actionLinkHovered: var(--neutralDark); + + /* ----------------------------------------------------------------------- + BUTTONS — Default + ----------------------------------------------------------------------- */ + --buttonBackground: var(--white); + --buttonBackgroundHovered: var(--neutralLighter); + --buttonBackgroundChecked: var(--neutralLight); + --buttonBackgroundCheckedHovered: var(--neutralQuaternaryAlt); + --buttonBackgroundPressed: var(--neutralLight); + --buttonBackgroundDisabled: var(--neutralLighter); + --buttonBorder: var(--neutralSecondaryAlt); + --buttonBorderDisabled: var(--neutralLighter); + --buttonText: var(--neutralPrimary); + --buttonTextHovered: var(--neutralDark); + --buttonTextChecked: var(--neutralDark); + --buttonTextCheckedHovered: var(--black); + --buttonTextPressed: var(--neutralDark); + --buttonTextDisabled: var(--neutralTertiary); + + /* ----------------------------------------------------------------------- + BUTTONS — Primary + ----------------------------------------------------------------------- */ + --primaryButtonBackground: var(--themePrimary); + --primaryButtonBackgroundHovered: var(--themeDarkAlt); + --primaryButtonBackgroundPressed: var(--themeDark); + --primaryButtonBackgroundDisabled: var(--neutralLighter); + --primaryButtonBorder: transparent; + --primaryButtonText: var(--white); + --primaryButtonTextHovered: var(--white); + --primaryButtonTextPressed: var(--white); + --primaryButtonTextDisabled: var(--neutralQuaternary); + + /* ----------------------------------------------------------------------- + BUTTONS — Accent + ----------------------------------------------------------------------- */ + --accentButtonBackground: var(--themePrimary); + --accentButtonText: var(--white); + + /* ----------------------------------------------------------------------- + INPUTS + ----------------------------------------------------------------------- */ + --inputBackground: var(--white); + --inputBackgroundChecked: var(--themePrimary); + --inputBackgroundCheckedHovered: var(--themeDarkAlt); + --inputPlaceholderBackgroundChecked: var(--themeLighter); + --inputForegroundChecked: var(--white); + --inputBorder: var(--neutralSecondary); + --smallInputBorder: var(--neutralSecondary); + --inputBorderHovered: var(--neutralPrimary); + --inputFocusBorderAlt: var(--themePrimary); + --inputText: var(--neutralPrimary); + --inputTextHovered: var(--neutralDark); + --inputPlaceholderText: var(--neutralSecondary); + --inputIcon: var(--themePrimary); + --inputIconHovered: var(--themeDark); + --inputIconDisabled: var(--neutralTertiary); + + /* ----------------------------------------------------------------------- + LISTS + ----------------------------------------------------------------------- */ + --listBackground: var(--white); + --listText: var(--neutralPrimary); + --listItemBackgroundHovered: var(--neutralLighter); + --listItemBackgroundChecked: var(--neutralLight); + --listItemBackgroundCheckedHovered: var(--neutralQuaternaryAlt); + --listHeaderBackgroundHovered: var(--neutralLighter); + --listHeaderBackgroundPressed: var(--neutralLight); + + /* ----------------------------------------------------------------------- + MENUS + ----------------------------------------------------------------------- */ + --menuBackground: var(--white); + --menuDivider: var(--neutralTertiaryAlt); + --menuIcon: var(--themePrimary); + --menuHeader: var(--themePrimary); + --menuItemBackgroundHovered: var(--neutralLighter); + --menuItemBackgroundPressed: var(--neutralLight); + --menuItemText: var(--neutralPrimary); + --menuItemTextHovered: var(--neutralDark); + + /* ----------------------------------------------------------------------- + CARDS + ----------------------------------------------------------------------- */ + --cardStandoutBackground: var(--white); + --cardShadow: var(--elevation4); + --cardShadowHovered: var(--elevation8); + --variantBorder: var(--neutralLight); + --variantBorderHovered: var(--neutralTertiaryAlt); + + /* ----------------------------------------------------------------------- + DISABLED / FOCUS + ----------------------------------------------------------------------- */ + --disabledBackground: var(--neutralLighter); + --disabledBorder: var(--neutralLighter); + --focusBorder: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + STATUS — Backgrounds + ----------------------------------------------------------------------- */ + --infoBackground: var(--neutralLighter); + --errorBackground: #FDE7E9; + --blockingBackground: #FDE7E9; + --warningBackground: #FFF4CE; + --severeWarningBackground: #FED9CC; + --successBackground: #DFF6DD; + + /* ----------------------------------------------------------------------- + STATUS — Icons + ----------------------------------------------------------------------- */ + --infoIcon: var(--neutralSecondary); + --errorIcon: #A80000; + --blockingIcon: #FDE7E9; + --warningIcon: #797775; + --severeWarningIcon: var(--orange); + --successIcon: var(--green); + + /* ----------------------------------------------------------------------- + STATUS — Text + ----------------------------------------------------------------------- */ + --errorText: var(--redDark); + --messageText: var(--neutralPrimary); + --messageLink: var(--themeDark); + --messageLinkHovered: var(--themeDarker); + --warningText: var(--neutralPrimary); + --successText: var(--green); + + /* ----------------------------------------------------------------------- + MISC + ----------------------------------------------------------------------- */ + --defaultStateBackground: var(--neutralLighterAlt); + --overlayBackground: var(--blackTranslucent40); +} diff --git a/default-themes/nebula-dusk.v3.css b/default-themes/nebula-dusk.v3.css new file mode 100644 index 0000000..939e985 --- /dev/null +++ b/default-themes/nebula-dusk.v3.css @@ -0,0 +1,201 @@ +/* ========================================================================== + Nebula Dusk — Dark Theme (v3) + Palette overrides + semantic tokens for FluentLM. + Rebrands from default blue to Nebula purple, dark mode. + ========================================================================== */ + +.nebula-dusk { + /* ----------------------------------------------------------------------- + PALETTE — Brand / Theme Colors (Nebula purple ramp) + ----------------------------------------------------------------------- */ + --themePrimary: #764ba2; + --themeDark: #5a3580; + --themeDarkAlt: #663d91; + --themeDarker: #3b2054; + --themeSecondary: #9670b8; + --themeTertiary: #b49bce; + --themeLight: #d3c3e3; + --themeLighter: #e8ddf1; + --themeLighterAlt: #f5f1fa; + + /* ----------------------------------------------------------------------- + PALETTE — Neutrals (inverted for dark mode) + ----------------------------------------------------------------------- */ + --black: #000000; + --neutralDarker: #0f0f23; + --neutralDark: #16213e; + --neutralPrimary: #1a1a2e; + --neutralPrimaryAlt: #222244; + --neutralSecondary: #2e2e52; + --neutralSecondaryAlt: #3a3a60; + --neutralTertiary: #5a5a80; + --neutralTertiaryAlt: #b794f6; + --neutralQuaternary: #7a7a9a; + --neutralQuaternaryAlt: #8a8aaa; + --neutralLight: #a0a0c0; + --neutralLighter: #e0e0e0; + --neutralLighterAlt: #f0f0f0; + --white: #ffffff; + + /* ----------------------------------------------------------------------- + BODY / BACKGROUND + ----------------------------------------------------------------------- */ + --bodyBackground: var(--neutralPrimary); + --bodyBackgroundHovered: var(--neutralPrimaryAlt); + --bodyBackgroundChecked: var(--neutralSecondary); + --bodyStandoutBackground: var(--neutralDark); + --bodyFrameBackground: var(--neutralPrimary); + --bodyFrameDivider: var(--neutralSecondary); + --bodyDivider: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + TEXT + ----------------------------------------------------------------------- */ + --bodyText: var(--neutralLighter); + --bodyTextChecked: var(--white); + --bodySubtext: var(--neutralTertiaryAlt); + --disabledText: var(--neutralSecondary); + --disabledBodyText: var(--neutralSecondary); + --disabledSubtext: var(--neutralSecondaryAlt); + --disabledBodySubtext: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + LINKS (Nebula purple instead of default blue) + ----------------------------------------------------------------------- */ + --link: #b794f6; + --linkHovered: #d4b8ff; + --actionLink: var(--neutralLighter); + --actionLinkHovered: var(--white); + + /* ----------------------------------------------------------------------- + BUTTONS — Default + ----------------------------------------------------------------------- */ + --buttonBackground: var(--neutralPrimary); + --buttonBackgroundHovered: var(--neutralPrimaryAlt); + --buttonBackgroundChecked: var(--neutralSecondary); + --buttonBackgroundCheckedHovered: var(--neutralSecondaryAlt); + --buttonBackgroundPressed: var(--neutralSecondary); + --buttonBackgroundDisabled: var(--neutralDark); + --buttonBorder: var(--neutralSecondaryAlt); + --buttonBorderDisabled: var(--neutralDark); + --buttonText: var(--neutralLighter); + --buttonTextHovered: var(--white); + --buttonTextChecked: var(--white); + --buttonTextCheckedHovered: var(--white); + --buttonTextPressed: var(--white); + --buttonTextDisabled: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + BUTTONS — Primary + ----------------------------------------------------------------------- */ + --primaryButtonBackground: var(--themePrimary); + --primaryButtonBackgroundHovered: var(--themeDarkAlt); + --primaryButtonBackgroundPressed: var(--themeDark); + --primaryButtonBackgroundDisabled: var(--neutralDark); + --primaryButtonBorder: transparent; + --primaryButtonText: var(--white); + --primaryButtonTextHovered: var(--white); + --primaryButtonTextPressed: var(--white); + --primaryButtonTextDisabled: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + BUTTONS — Accent + ----------------------------------------------------------------------- */ + --accentButtonBackground: var(--themePrimary); + --accentButtonText: var(--white); + + /* ----------------------------------------------------------------------- + INPUTS + ----------------------------------------------------------------------- */ + --inputBackground: var(--neutralPrimary); + --inputBackgroundChecked: var(--themePrimary); + --inputBackgroundCheckedHovered: var(--themeDarkAlt); + --inputPlaceholderBackgroundChecked: var(--themeDark); + --inputForegroundChecked: var(--white); + --inputBorder: var(--neutralTertiary); + --smallInputBorder: var(--neutralTertiary); + --inputBorderHovered: var(--neutralLighter); + --inputFocusBorderAlt: var(--themeSecondary); + --inputText: var(--neutralLighter); + --inputTextHovered: var(--white); + --inputPlaceholderText: var(--neutralTertiaryAlt); + --inputIcon: var(--themeSecondary); + --inputIconHovered: var(--themePrimary); + --inputIconDisabled: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + LISTS + ----------------------------------------------------------------------- */ + --listBackground: var(--neutralPrimary); + --listText: var(--neutralLighter); + --listItemBackgroundHovered: var(--neutralPrimaryAlt); + --listItemBackgroundChecked: var(--neutralSecondary); + --listItemBackgroundCheckedHovered: var(--neutralSecondaryAlt); + --listHeaderBackgroundHovered: var(--neutralPrimaryAlt); + --listHeaderBackgroundPressed: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + MENUS + ----------------------------------------------------------------------- */ + --menuBackground: var(--neutralPrimary); + --menuDivider: var(--neutralSecondary); + --menuIcon: var(--themeSecondary); + --menuHeader: var(--themeSecondary); + --menuItemBackgroundHovered: var(--neutralPrimaryAlt); + --menuItemBackgroundPressed: var(--neutralSecondary); + --menuItemText: var(--neutralLighter); + --menuItemTextHovered: var(--white); + + /* ----------------------------------------------------------------------- + CARDS + In dark themes Fluent v8 drops shadows and uses borders instead. + ----------------------------------------------------------------------- */ + --cardStandoutBackground: var(--neutralDark); + --cardShadow: none; + --cardShadowHovered: none; + --variantBorder: var(--neutralSecondary); + --variantBorderHovered: var(--neutralTertiary); + + /* ----------------------------------------------------------------------- + DISABLED / FOCUS + ----------------------------------------------------------------------- */ + --disabledBackground: var(--neutralDark); + --disabledBorder: var(--neutralDark); + --focusBorder: var(--neutralTertiaryAlt); + + /* ----------------------------------------------------------------------- + STATUS — Backgrounds + ----------------------------------------------------------------------- */ + --infoBackground: var(--neutralPrimary); + --errorBackground: #442726; + --blockingBackground: #442726; + --warningBackground: #433519; + --severeWarningBackground: #4F2A0F; + --successBackground: #393D1B; + + /* ----------------------------------------------------------------------- + STATUS — Icons + ----------------------------------------------------------------------- */ + --infoIcon: var(--neutralTertiaryAlt); + --errorIcon: #F1707B; + --blockingIcon: #442726; + --warningIcon: var(--neutralTertiaryAlt); + --severeWarningIcon: #FCE100; + --successIcon: #92C353; + + /* ----------------------------------------------------------------------- + STATUS — Text + ----------------------------------------------------------------------- */ + --errorText: #F1707B; + --messageText: var(--neutralLighter); + --messageLink: #b794f6; + --messageLinkHovered: #d4b8ff; + --warningText: var(--neutralLighter); + --successText: #92C353; + + /* ----------------------------------------------------------------------- + MISC + ----------------------------------------------------------------------- */ + --defaultStateBackground: var(--neutralDark); + --overlayBackground: rgba(0, 0, 0, 0.6); +} diff --git a/default-themes/solar-flare-dawn.json b/default-themes/solar-flare-dawn.json new file mode 100644 index 0000000..6b23132 --- /dev/null +++ b/default-themes/solar-flare-dawn.json @@ -0,0 +1,19 @@ +{ + "mode": "light", + "colors": { + "bg-primary": "#f6efe4", + "bg-secondary": "#f0e6d8", + "bg-tertiary": "#faf7f2", + "accent-primary": "#e67e22", + "accent-secondary": "#b8621a", + "accent-tertiary": "#f0b67e", + "accent-glow": "rgba(230,126,34,0.15)", + "text-primary": "#402d18", + "text-secondary": "#8a6840", + "border-color": "rgba(230,126,34,0.25)", + "header-min-height": "58px", + "header-padding-vertical": "14px", + "header-padding-horizontal": "20px", + "header-line-height": "1.25" + } +} diff --git a/default-themes/solar-flare-dawn.v3.css b/default-themes/solar-flare-dawn.v3.css new file mode 100644 index 0000000..95ccd88 --- /dev/null +++ b/default-themes/solar-flare-dawn.v3.css @@ -0,0 +1,198 @@ +/* ========================================================================== + Solar Flare Dawn — Light Theme (v3) + Palette overrides + semantic tokens for FluentLM. + Rebrands from default blue to Solar Flare amber/orange. + ========================================================================== */ + +.solar-flare-dawn { + /* ----------------------------------------------------------------------- + PALETTE — Brand / Theme Colors (Solar Flare amber ramp) + ----------------------------------------------------------------------- */ + --themePrimary: #e67e22; + --themeDark: #b8621a; + --themeDarkAlt: #cf701e; + --themeDarker: #7a4310; + --themeSecondary: #eb9950; + --themeTertiary: #f0b67e; + --themeLight: #f7d6b3; + --themeLighter: #fbe9d5; + --themeLighterAlt: #fdf5ec; + + /* ----------------------------------------------------------------------- + PALETTE — Neutrals (warm amber-tinted) + ----------------------------------------------------------------------- */ + --neutralDark: #2e1e0e; + --neutralPrimary: #402d18; + --neutralPrimaryAlt: #563a1e; + --neutralSecondary: #8a6840; + --neutralSecondaryAlt: #795a36; + --neutralTertiary: #9d8a70; + --neutralTertiaryAlt: #b5a48e; + --neutralQuaternary: #d6cabb; + --neutralQuaternaryAlt: #e0d5c6; + --neutralLight: #f0e6d8; + --neutralLighter: #f6efe4; + --neutralLighterAlt: #faf7f2; + --white: #ffffff; + + /* ----------------------------------------------------------------------- + BODY / BACKGROUND + ----------------------------------------------------------------------- */ + --bodyBackground: var(--white); + --bodyBackgroundHovered: var(--neutralLighter); + --bodyBackgroundChecked: var(--neutralLight); + --bodyStandoutBackground: var(--neutralLighterAlt); + --bodyFrameBackground: var(--white); + --bodyFrameDivider: var(--neutralLight); + --bodyDivider: var(--neutralLight); + + /* ----------------------------------------------------------------------- + TEXT + ----------------------------------------------------------------------- */ + --bodyText: var(--neutralPrimary); + --bodyTextChecked: var(--neutralDark); + --bodySubtext: var(--neutralSecondary); + --disabledText: var(--neutralTertiary); + --disabledBodyText: var(--neutralTertiary); + --disabledSubtext: var(--neutralQuaternary); + --disabledBodySubtext: var(--neutralTertiaryAlt); + + /* ----------------------------------------------------------------------- + LINKS + ----------------------------------------------------------------------- */ + --link: var(--themePrimary); + --linkHovered: var(--themeDarker); + --actionLink: var(--neutralPrimary); + --actionLinkHovered: var(--neutralDark); + + /* ----------------------------------------------------------------------- + BUTTONS — Default + ----------------------------------------------------------------------- */ + --buttonBackground: var(--white); + --buttonBackgroundHovered: var(--neutralLighter); + --buttonBackgroundChecked: var(--neutralLight); + --buttonBackgroundCheckedHovered: var(--neutralQuaternaryAlt); + --buttonBackgroundPressed: var(--neutralLight); + --buttonBackgroundDisabled: var(--neutralLighter); + --buttonBorder: var(--neutralSecondaryAlt); + --buttonBorderDisabled: var(--neutralLighter); + --buttonText: var(--neutralPrimary); + --buttonTextHovered: var(--neutralDark); + --buttonTextChecked: var(--neutralDark); + --buttonTextCheckedHovered: var(--black); + --buttonTextPressed: var(--neutralDark); + --buttonTextDisabled: var(--neutralTertiary); + + /* ----------------------------------------------------------------------- + BUTTONS — Primary + ----------------------------------------------------------------------- */ + --primaryButtonBackground: var(--themePrimary); + --primaryButtonBackgroundHovered: var(--themeDarkAlt); + --primaryButtonBackgroundPressed: var(--themeDark); + --primaryButtonBackgroundDisabled: var(--neutralLighter); + --primaryButtonBorder: transparent; + --primaryButtonText: var(--white); + --primaryButtonTextHovered: var(--white); + --primaryButtonTextPressed: var(--white); + --primaryButtonTextDisabled: var(--neutralQuaternary); + + /* ----------------------------------------------------------------------- + BUTTONS — Accent + ----------------------------------------------------------------------- */ + --accentButtonBackground: var(--themePrimary); + --accentButtonText: var(--white); + + /* ----------------------------------------------------------------------- + INPUTS + ----------------------------------------------------------------------- */ + --inputBackground: var(--white); + --inputBackgroundChecked: var(--themePrimary); + --inputBackgroundCheckedHovered: var(--themeDarkAlt); + --inputPlaceholderBackgroundChecked: var(--themeLighter); + --inputForegroundChecked: var(--white); + --inputBorder: var(--neutralSecondary); + --smallInputBorder: var(--neutralSecondary); + --inputBorderHovered: var(--neutralPrimary); + --inputFocusBorderAlt: var(--themePrimary); + --inputText: var(--neutralPrimary); + --inputTextHovered: var(--neutralDark); + --inputPlaceholderText: var(--neutralSecondary); + --inputIcon: var(--themePrimary); + --inputIconHovered: var(--themeDark); + --inputIconDisabled: var(--neutralTertiary); + + /* ----------------------------------------------------------------------- + LISTS + ----------------------------------------------------------------------- */ + --listBackground: var(--white); + --listText: var(--neutralPrimary); + --listItemBackgroundHovered: var(--neutralLighter); + --listItemBackgroundChecked: var(--neutralLight); + --listItemBackgroundCheckedHovered: var(--neutralQuaternaryAlt); + --listHeaderBackgroundHovered: var(--neutralLighter); + --listHeaderBackgroundPressed: var(--neutralLight); + + /* ----------------------------------------------------------------------- + MENUS + ----------------------------------------------------------------------- */ + --menuBackground: var(--white); + --menuDivider: var(--neutralTertiaryAlt); + --menuIcon: var(--themePrimary); + --menuHeader: var(--themePrimary); + --menuItemBackgroundHovered: var(--neutralLighter); + --menuItemBackgroundPressed: var(--neutralLight); + --menuItemText: var(--neutralPrimary); + --menuItemTextHovered: var(--neutralDark); + + /* ----------------------------------------------------------------------- + CARDS + ----------------------------------------------------------------------- */ + --cardStandoutBackground: var(--white); + --cardShadow: var(--elevation4); + --cardShadowHovered: var(--elevation8); + --variantBorder: var(--neutralLight); + --variantBorderHovered: var(--neutralTertiaryAlt); + + /* ----------------------------------------------------------------------- + DISABLED / FOCUS + ----------------------------------------------------------------------- */ + --disabledBackground: var(--neutralLighter); + --disabledBorder: var(--neutralLighter); + --focusBorder: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + STATUS — Backgrounds + ----------------------------------------------------------------------- */ + --infoBackground: var(--neutralLighter); + --errorBackground: #FDE7E9; + --blockingBackground: #FDE7E9; + --warningBackground: #FFF4CE; + --severeWarningBackground: #FED9CC; + --successBackground: #DFF6DD; + + /* ----------------------------------------------------------------------- + STATUS — Icons + ----------------------------------------------------------------------- */ + --infoIcon: var(--neutralSecondary); + --errorIcon: #A80000; + --blockingIcon: #FDE7E9; + --warningIcon: #797775; + --severeWarningIcon: var(--orange); + --successIcon: var(--green); + + /* ----------------------------------------------------------------------- + STATUS — Text + ----------------------------------------------------------------------- */ + --errorText: var(--redDark); + --messageText: var(--neutralPrimary); + --messageLink: var(--themeDark); + --messageLinkHovered: var(--themeDarker); + --warningText: var(--neutralPrimary); + --successText: var(--green); + + /* ----------------------------------------------------------------------- + MISC + ----------------------------------------------------------------------- */ + --defaultStateBackground: var(--neutralLighterAlt); + --overlayBackground: var(--blackTranslucent40); +} diff --git a/default-themes/solar-flare-dusk.json b/default-themes/solar-flare-dusk.json new file mode 100644 index 0000000..2ace126 --- /dev/null +++ b/default-themes/solar-flare-dusk.json @@ -0,0 +1,19 @@ +{ + "mode": "dark", + "colors": { + "bg-primary": "#2e1e0e", + "bg-secondary": "#241a10", + "bg-tertiary": "#1a120a", + "accent-primary": "#e67e22", + "accent-secondary": "#b8621a", + "accent-tertiary": "#f0b67e", + "accent-glow": "rgba(230,126,34,0.3)", + "text-primary": "#e0d5c6", + "text-secondary": "#f0b67e", + "border-color": "rgba(230,126,34,0.3)", + "header-min-height": "58px", + "header-padding-vertical": "14px", + "header-padding-horizontal": "20px", + "header-line-height": "1.25" + } +} diff --git a/default-themes/solar-flare-dusk.v3.css b/default-themes/solar-flare-dusk.v3.css new file mode 100644 index 0000000..cab43e1 --- /dev/null +++ b/default-themes/solar-flare-dusk.v3.css @@ -0,0 +1,200 @@ +/* ========================================================================== + Solar Flare Dusk — Dark Theme (v3) + Palette overrides + semantic tokens for FluentLM. + Rebrands from default blue to Solar Flare amber/orange, dark mode. + ========================================================================== */ + +.solar-flare-dusk { + /* ----------------------------------------------------------------------- + PALETTE — Brand / Theme Colors (Solar Flare amber ramp) + ----------------------------------------------------------------------- */ + --themePrimary: #e67e22; + --themeDark: #b8621a; + --themeDarkAlt: #cf701e; + --themeDarker: #7a4310; + --themeSecondary: #eb9950; + --themeTertiary: #f0b67e; + --themeLight: #f7d6b3; + --themeLighter: #fbe9d5; + --themeLighterAlt: #fdf5ec; + + /* ----------------------------------------------------------------------- + PALETTE — Neutrals (inverted for dark mode, warm amber-tinted) + ----------------------------------------------------------------------- */ + --black: #000000; + --neutralDarker: #1a120a; + --neutralDark: #241a10; + --neutralPrimary: #2e1e0e; + --neutralPrimaryAlt: #3c2a16; + --neutralSecondary: #52381e; + --neutralSecondaryAlt: #6a4a28; + --neutralTertiary: #8a6840; + --neutralTertiaryAlt: #f0b67e; + --neutralQuaternary: #9d8a70; + --neutralQuaternaryAlt: #b5a48e; + --neutralLight: #c8b898; + --neutralLighter: #e0d5c6; + --neutralLighterAlt: #f0e6d8; + --white: #ffffff; + + /* ----------------------------------------------------------------------- + BODY / BACKGROUND + ----------------------------------------------------------------------- */ + --bodyBackground: var(--neutralPrimary); + --bodyBackgroundHovered: var(--neutralPrimaryAlt); + --bodyBackgroundChecked: var(--neutralSecondary); + --bodyStandoutBackground: var(--neutralDark); + --bodyFrameBackground: var(--neutralPrimary); + --bodyFrameDivider: var(--neutralSecondary); + --bodyDivider: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + TEXT + ----------------------------------------------------------------------- */ + --bodyText: var(--neutralLighter); + --bodyTextChecked: var(--white); + --bodySubtext: var(--neutralTertiaryAlt); + --disabledText: var(--neutralSecondary); + --disabledBodyText: var(--neutralSecondary); + --disabledSubtext: var(--neutralSecondaryAlt); + --disabledBodySubtext: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + LINKS + ----------------------------------------------------------------------- */ + --link: #f0b67e; + --linkHovered: #f7d6b3; + --actionLink: var(--neutralLighter); + --actionLinkHovered: var(--white); + + /* ----------------------------------------------------------------------- + BUTTONS — Default + ----------------------------------------------------------------------- */ + --buttonBackground: var(--neutralPrimary); + --buttonBackgroundHovered: var(--neutralPrimaryAlt); + --buttonBackgroundChecked: var(--neutralSecondary); + --buttonBackgroundCheckedHovered: var(--neutralSecondaryAlt); + --buttonBackgroundPressed: var(--neutralSecondary); + --buttonBackgroundDisabled: var(--neutralDark); + --buttonBorder: var(--neutralSecondaryAlt); + --buttonBorderDisabled: var(--neutralDark); + --buttonText: var(--neutralLighter); + --buttonTextHovered: var(--white); + --buttonTextChecked: var(--white); + --buttonTextCheckedHovered: var(--white); + --buttonTextPressed: var(--white); + --buttonTextDisabled: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + BUTTONS — Primary + ----------------------------------------------------------------------- */ + --primaryButtonBackground: var(--themePrimary); + --primaryButtonBackgroundHovered: var(--themeDarkAlt); + --primaryButtonBackgroundPressed: var(--themeDark); + --primaryButtonBackgroundDisabled: var(--neutralDark); + --primaryButtonBorder: transparent; + --primaryButtonText: var(--white); + --primaryButtonTextHovered: var(--white); + --primaryButtonTextPressed: var(--white); + --primaryButtonTextDisabled: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + BUTTONS — Accent + ----------------------------------------------------------------------- */ + --accentButtonBackground: var(--themePrimary); + --accentButtonText: var(--white); + + /* ----------------------------------------------------------------------- + INPUTS + ----------------------------------------------------------------------- */ + --inputBackground: var(--neutralPrimary); + --inputBackgroundChecked: var(--themePrimary); + --inputBackgroundCheckedHovered: var(--themeDarkAlt); + --inputPlaceholderBackgroundChecked: var(--themeDark); + --inputForegroundChecked: var(--white); + --inputBorder: var(--neutralTertiary); + --smallInputBorder: var(--neutralTertiary); + --inputBorderHovered: var(--neutralLighter); + --inputFocusBorderAlt: var(--themeSecondary); + --inputText: var(--neutralLighter); + --inputTextHovered: var(--white); + --inputPlaceholderText: var(--neutralTertiaryAlt); + --inputIcon: var(--themeSecondary); + --inputIconHovered: var(--themePrimary); + --inputIconDisabled: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + LISTS + ----------------------------------------------------------------------- */ + --listBackground: var(--neutralPrimary); + --listText: var(--neutralLighter); + --listItemBackgroundHovered: var(--neutralPrimaryAlt); + --listItemBackgroundChecked: var(--neutralSecondary); + --listItemBackgroundCheckedHovered: var(--neutralSecondaryAlt); + --listHeaderBackgroundHovered: var(--neutralPrimaryAlt); + --listHeaderBackgroundPressed: var(--neutralSecondary); + + /* ----------------------------------------------------------------------- + MENUS + ----------------------------------------------------------------------- */ + --menuBackground: var(--neutralPrimary); + --menuDivider: var(--neutralSecondary); + --menuIcon: var(--themeSecondary); + --menuHeader: var(--themeSecondary); + --menuItemBackgroundHovered: var(--neutralPrimaryAlt); + --menuItemBackgroundPressed: var(--neutralSecondary); + --menuItemText: var(--neutralLighter); + --menuItemTextHovered: var(--white); + + /* ----------------------------------------------------------------------- + CARDS + ----------------------------------------------------------------------- */ + --cardStandoutBackground: var(--neutralDark); + --cardShadow: none; + --cardShadowHovered: none; + --variantBorder: var(--neutralSecondary); + --variantBorderHovered: var(--neutralTertiary); + + /* ----------------------------------------------------------------------- + DISABLED / FOCUS + ----------------------------------------------------------------------- */ + --disabledBackground: var(--neutralDark); + --disabledBorder: var(--neutralDark); + --focusBorder: var(--neutralTertiaryAlt); + + /* ----------------------------------------------------------------------- + STATUS — Backgrounds + ----------------------------------------------------------------------- */ + --infoBackground: var(--neutralPrimary); + --errorBackground: #442726; + --blockingBackground: #442726; + --warningBackground: #433519; + --severeWarningBackground: #4F2A0F; + --successBackground: #393D1B; + + /* ----------------------------------------------------------------------- + STATUS — Icons + ----------------------------------------------------------------------- */ + --infoIcon: var(--neutralTertiaryAlt); + --errorIcon: #F1707B; + --blockingIcon: #442726; + --warningIcon: var(--neutralTertiaryAlt); + --severeWarningIcon: #FCE100; + --successIcon: #92C353; + + /* ----------------------------------------------------------------------- + STATUS — Text + ----------------------------------------------------------------------- */ + --errorText: #F1707B; + --messageText: var(--neutralLighter); + --messageLink: #f0b67e; + --messageLinkHovered: #f7d6b3; + --warningText: var(--neutralLighter); + --successText: #92C353; + + /* ----------------------------------------------------------------------- + MISC + ----------------------------------------------------------------------- */ + --defaultStateBackground: var(--neutralDark); + --overlayBackground: rgba(0, 0, 0, 0.6); +} diff --git a/docs/page-rendering-writeup.md b/docs/page-rendering-writeup.md new file mode 100644 index 0000000..3905578 --- /dev/null +++ b/docs/page-rendering-writeup.md @@ -0,0 +1,238 @@ +# How Page Rendering Works in SynthOS + +*Audience: Product Managers familiar with Generative UX (GenUX) concepts* + +--- + +## The One-Sentence Version + +SynthOS turns natural language into live web apps by running a **server-side DOM surgery loop**: the user types a message, an LLM produces a list of surgical DOM operations, the server applies them with cheerio, and the browser receives a complete, updated HTML document. + +There is no client-side framework. No virtual DOM. No component tree. The HTML *is* the app, and the LLM *is* the builder. + +--- + +## The Core Loop + +Every interaction follows the same cycle: + +``` +User message → Annotate DOM → LLM → Apply changes → Serve HTML +``` + +Let's walk through each stage. + +### 1. The User Sends a Message + +The browser-side chat panel is a ` + +``` + +### Static script versions + +v3 pages reference the v3 static files. Update both script tags at the bottom of every page: + +```html + + + + + + + +``` + +### Migration rules for the LLM (shell chrome) + +When migrating a v2 page's shell chrome to v3: + +1. **Replace the chat input** — swap the `` + `` pair with a single ` + + +
+
+
+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/required-pages/builder/page.json b/required-pages/builder/page.json new file mode 100644 index 0000000..da4cc68 --- /dev/null +++ b/required-pages/builder/page.json @@ -0,0 +1,10 @@ +{ + "title": "Page Builder", + "categories": [ + "System" + ], + "pinned": true, + "showInAll": true, + "pageVersion": 3, + "mode": "unlocked" +} diff --git a/required-pages/pages.json b/required-pages/pages.json deleted file mode 100644 index a5293cd..0000000 --- a/required-pages/pages.json +++ /dev/null @@ -1 +0,0 @@ -{ "title": "Pages", "categories": ["System"], "pinned": true, "showInAll": true, "pageVersion": 2, "mode": "locked" } diff --git a/required-pages/pages.html b/required-pages/pages/page.html similarity index 57% rename from required-pages/pages.html rename to required-pages/pages/page.html index 3d74315..e802190 100644 --- a/required-pages/pages.html +++ b/required-pages/pages/page.html @@ -1,919 +1,924 @@ - - - - SynthOS - Pages - - - - - - - - - - -
-
SynthOS
-
-
-

SynthOS: Here are all of the created pages. Right-click any page to pin it to - favorites, edit its definition, or copy it to a new page.

-
-
- -
- - -
-
-
-
Pages
- - - - - - -
-

Pages

-
-
- -
- - -
-
-
- - - - - - - - - -
-
- - -
-
-
Edit page definition
-
Copy to new page
-
Update page
-
-
Download page
-
- - - - -
- - - - - + + + + SynthOS - Pages + + + + + + + + + + + +
+ + + +
+ +
+
+
Page Builder
+
+
+

SynthOS: Here are all of the created pages. Right-click any page to pin it to + favorites, edit its definition, or copy it to a new page.

+
+
+
+ +
+
+
+
+ +
+ Pages +
+ +
+
+ + + +
+
+
+ +
+
+ + +
+ + + + + +
+ Pages +
+
+
+ + +
+
+
+

Edit Page Definition

+ +
+
+
+
+ + + Page name: +
+
+ + +
+ +
+ + Locked pages are read-only and cannot be modified through chat. +
+
+ + When unchecked, this page is hidden from the "All" filter and only appears under its category. +
+
+
+ +
+
+ + +
+
+
+

Copy to New Page

+ +
+
+
+
+ + + Source: +
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+
+
+

Confirm Delete

+ +
+
+

Are you sure you want to delete ""? This cannot be undone.

+
+ +
+
+
+
+ + +
+ + + + +
+ +
+ + + + +
+ + + + + diff --git a/required-pages/pages/page.json b/required-pages/pages/page.json new file mode 100644 index 0000000..eb71621 --- /dev/null +++ b/required-pages/pages/page.json @@ -0,0 +1,10 @@ +{ + "title": "Pages", + "categories": [ + "System" + ], + "pinned": true, + "showInAll": true, + "pageVersion": 3, + "mode": "locked" +} diff --git a/required-pages/settings.json b/required-pages/settings.json deleted file mode 100644 index c5e2618..0000000 --- a/required-pages/settings.json +++ /dev/null @@ -1 +0,0 @@ -{ "title": "Settings", "categories": ["System"], "pinned": true, "showInAll": true, "pageVersion": 2, "mode": "locked" } diff --git a/required-pages/settings.html b/required-pages/settings/page.html similarity index 62% rename from required-pages/settings.html rename to required-pages/settings/page.html index 655e61c..5ffab31 100644 --- a/required-pages/settings.html +++ b/required-pages/settings/page.html @@ -1,1639 +1,1753 @@ - - - - SynthOS - Settings - - - - - - - - -
-
SynthOS
-
-
-

SynthOS: Configure your settings below. Use the accordion sections to navigate between General settings, Model configuration (Page Builder & Chat Completion), and Additional Features.

-

The Page Builder Model is used when building pages via chat. The Chat Model is used by pages that call synthos.generate.completion().

-
- -
- -
- - -
-
-
-
Settings
-
- -
- -
-
-
- - -
-
-
- -
-
-
- -
- -
-
- - -
-
Page Builder Model
-
- - -
-
- - - -
-
- - -
- ▾ More settings -
- - -
-
- - -
-
- -
-
Chat Model
-
- - -
-
- - -
-
- - -
- ▾ More settings -
- - -
-
- - -
-
- -
-
- -
-
-
- -
- -
-
-
-
- - -
-
-
-
-
- -
- -
-
-
-
Configure agents (A2A or OpenClaw) that your pages can communicate with.
- -
-
- -
-
- Chat with Agent - -
-
-
- - -
-
-
-
-
- -
-
-
- - - - - - - - + + + + SynthOS - Settings + + + + + + + + + +
+ + + +
+ +
+
+
Page Builder
+
+
+

SynthOS: This is where you can customize your experience. Change your theme and toolbar layout under Appearance, configure AI models under Page Building & Chat, or set up integrations under Connectors and Agents.

+

Click Apply when you're ready to save your changes.

+
+ +
+
+ +
+
+
+
+ Settings +
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+
+
+ +
+ +
+
+ + +
+ Page Builder Model +
+ + +
+
+ + + +
+
+ + +
+ ▾ More settings +
+ + +
+
+ + +
+
+ +
+ Chat Model +
+ + +
+
+ + +
+
+ + +
+ ▾ More settings +
+ + +
+
+ + +
+
+ +
+
+
+ +
+ +
+
+
+
+ + +
+
+
+
+
+ +
+ +
+
+
+ Configure agents (A2A or OpenClaw) that your pages can communicate with. + +
+
+ +
+
+ Chat with Agent + +
+
+
+ + +
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+

Add Agent

+
+
+
+
+ +
+ + +
+
+
+ +
+ + +
+ Enter a URL and click Discover to auto-fill from the agent card, or fill in the fields manually. +
+ + + +
+ + +
+
+ + +
+ +
+
+ +
+
+
+
+
+

+
+
+
+ + +
+ + +
+ +
+ + +
+
+
+ +
+
+
+
+
+

Unsaved Changes

+
+
+

You have unsaved changes. Do you want to discard them and leave this page?

+
+ +
+
+ + + + + + diff --git a/required-pages/settings/page.json b/required-pages/settings/page.json new file mode 100644 index 0000000..0d820d8 --- /dev/null +++ b/required-pages/settings/page.json @@ -0,0 +1,10 @@ +{ + "title": "Settings", + "categories": [ + "System" + ], + "pinned": true, + "showInAll": true, + "pageVersion": 3, + "mode": "locked" +} diff --git a/required-pages/synthos_apis.html b/required-pages/synthos_apis.html deleted file mode 100644 index 3f2567f..0000000 --- a/required-pages/synthos_apis.html +++ /dev/null @@ -1,327 +0,0 @@ - - - - SynthOS - APIs - - - - - - - - - -
-
SynthOS
-
-

SynthOS: Expand the individual API operations to test calls.

-
- -
- - -
-
-
-
API Explorer
-
-
-
GET /api/data/:page/:table
-
- This operation retrieves all rows from the specified page-scoped table. The response is an array of JSON objects, each representing a row in the table. -
- - - -
-
-
-
-
-
GET /api/data/:page/:table?limit=N&offset=N
-
- Paginated variant. Returns a page of rows from the table. The response includes the items, total count, and whether more rows are available. - Response: { items: [...], total: number, offset: number, limit: number, hasMore: boolean } -
- - - - - -
-
-
-
-
-
GET /api/data/:page/:table/:id
-
- This operation retrieves a single row from the specified page-scoped table using the provided ID. The response is a JSON object representing the row. -
- - - - -
-
-
-
-
-
POST /api/data/:page/:table
-
- This operation saves a single row to the specified page-scoped table. The request should include a JSON object representing the row. The response indicates success. -
- - - - -
-
-
-
-
-
DELETE /api/data/:page/:table/:id
-
- This operation deletes a single row from the specified page-scoped table using the provided ID. The response indicates success. -
- - - - -
-
-
-
-
-
GET /api/pages
-
- This operation retrieves a list of all available pages. The response is an array of page objects with name, title, categories, pinned, createdDate, lastModified, pageVersion, and mode. -
- -
-
-
-
-
-
GET /api/pages/:name
-
- This operation retrieves the metadata for a single page, including title, categories, pinned, createdDate, lastModified, pageVersion, and mode. -
- - -
-
-
-
-
-
POST /api/pages/:name
-
- This operation merges metadata for a page. Send only the fields you want to update. Supported fields: title (string), categories (array of strings), pinned (boolean), mode ("unlocked" | "locked"). The lastModified timestamp is auto-set on each update. The createdDate and pageVersion fields are preserved and cannot be overridden. -
- - - -
-
-
-
-
-
POST /api/pages/:name/pin
-
- This operation toggles the pinned status for a page. The request body should include pinned (boolean). -
- - - -
-
-
-
-
-
DELETE /api/pages/:name
-
- This operation deletes a user page. Required (system) pages cannot be deleted. Returns 400 if the page is a required page, or 404 if not found. -
- - -
-
-
-
-
-
POST /api/pages/:name/copy
-
- This operation copies a page to a new name. The source page can be a user page or a required (system) page. The request body should include name (string, required), title (string, optional), and categories (array of strings, optional). Returns 409 if the target page already exists. -
- - - -
-
-
-
-
-
POST /api/search/web
-
- This operation searches the web using Brave Search and returns a list of results. Requires Brave Search to be enabled in Settings > Connectors. -
- - - -
-
-
-
-
-
POST /api/generate/image
-
- This operation generates an image based on a prompt. You can specify the shape and style of the image. -
- - - - -
-
-
-
-
-
POST /api/generate/completion
-
- This operation generates a text completion based on a prompt. You can optionally specify the temperature for controlling randomness. -
- - - -
-
-
-
-
-
POST /api/scripts/:id
-
- This operation executes a script with the specified ID and passes in the provided variables. The response contains the output of the script execution. -
- - - -
-
-
-
-
-
GET /api/connectors
-
- Lists all available connectors with their configuration status. Supports optional category and id query filters. -
- - -
-
-
-
-
-
GET /api/connectors/:id
-
- Retrieves full detail for a single connector, including its definition and whether it is configured and enabled. -
- - -
-
-
-
-
-
POST /api/connectors (proxy call)
-
- Proxies a request through a configured connector. The connector attaches authentication automatically based on its auth strategy. -
- - - - - - -
-
-
-
-
-
GET /api/agents
-
- Lists configured agents (A2A and OpenClaw). Supports optional filters: enabled (boolean) and provider (a2a|openclaw). Returns id, name, description, url, enabled, provider, and capabilities for each agent. -
- - - -
-
-
-
-
-
POST /api/agents/:id/send
-
- Sends a text message to an agent (works for both A2A and OpenClaw protocols). Returns a normalized response: { kind: 'message'|'task', text?: string, raw: object }. Optionally include an attachments array: [{ fileName: string, mimeType: string, content: string (base64 for binary) }]. -
- - - -
-
-
-
-
-
POST /api/agents/:id/stream
-
- Sends a message and receives a streaming SSE response. Each event is JSON: { kind: 'text'|'status'|'artifact'|'done'|'error', data: any }. The stream ends with a [DONE] sentinel. Optionally include an attachments array: [{ fileName: string, mimeType: string, content: string (base64 for binary) }]. -
- - - -
-
-
-
-
-
PATCH /api/agents/:id
-
- Toggle an agent's enabled/disabled state or update its name and description. Send only the fields to change. -
- - - -
-
-
-
-
-
-
- - - - - - \ No newline at end of file diff --git a/required-pages/synthos_apis.json b/required-pages/synthos_apis.json deleted file mode 100644 index 43154b5..0000000 --- a/required-pages/synthos_apis.json +++ /dev/null @@ -1 +0,0 @@ -{ "title": "SynthOS APIs", "categories": ["System"], "pinned": false, "showInAll": false, "pageVersion": 2, "mode": "locked" } diff --git a/required-pages/synthos_apis/page.html b/required-pages/synthos_apis/page.html new file mode 100644 index 0000000..255b010 --- /dev/null +++ b/required-pages/synthos_apis/page.html @@ -0,0 +1,846 @@ + + + + SynthOS - APIs + + + + + + +
+ + + +
+ +
+
+
Page Builder
+
+

SynthOS: Expand individual API sections to test calls. Use the tabs to switch between API groups.

+
+
+ +
+
+
+
+ API Explorer +

Interactive reference for all SynthOS REST endpoints.

+ + +
+
+
+ + + + + + \ No newline at end of file diff --git a/required-pages/synthos_apis/page.json b/required-pages/synthos_apis/page.json new file mode 100644 index 0000000..ef75b2e --- /dev/null +++ b/required-pages/synthos_apis/page.json @@ -0,0 +1,10 @@ +{ + "title": "SynthOS APIs", + "categories": [ + "System" + ], + "pinned": false, + "showInAll": false, + "pageVersion": 3, + "mode": "locked" +} diff --git a/required-pages/synthos_scripts.json b/required-pages/synthos_scripts.json deleted file mode 100644 index 782e617..0000000 --- a/required-pages/synthos_scripts.json +++ /dev/null @@ -1 +0,0 @@ -{ "title": "SynthOS Scripts", "categories": ["System"], "pinned": false, "showInAll": false, "pageVersion": 2, "mode": "locked" } diff --git a/required-pages/synthos_scripts.html b/required-pages/synthos_scripts/page.html similarity index 77% rename from required-pages/synthos_scripts.html rename to required-pages/synthos_scripts/page.html index 23500b1..6b4ce8f 100644 --- a/required-pages/synthos_scripts.html +++ b/required-pages/synthos_scripts/page.html @@ -1,87 +1,89 @@ - - - - SynthOS - Scripts - - - - - - - - - -
-
SynthOS
-
-

SynthOS: Add or modify scripts that can be executed using the /api/scripts/:id API.

-
- -
- - -
-
-
-
Script Editor
-
-
-
    - -
- -
-
-
- Select an existing script to edit or add a new one using the "Add New Script" button. -
- - - - - -
Script Command is required.
- - -
-

Understanding SynthOS Scripts

-

Scripts in SynthOS are powerful tools that allow you to define custom terminal commands for various tasks. When SynthOS executes a script, it runs the command and captures the console output, which is then returned for further processing or analysis.

- -

Creating Effective Scripts

-

When writing a script, you can use any valid terminal command. For added flexibility, you can include {{variable}} placeholders, which SynthOS will replace with actual values during execution.

- -

Example: Weather Forecast Script

-

Here's an example of an interesting script that SynthOS could use:

-

curl wttr.in/{{city}}?format=3

-

This script fetches a concise weather forecast for a specified city. SynthOS can call this script with different city names to get up-to-date weather information.

- -

Tips for Script Writing

-

1. Keep commands concise and focused on a single task.

-

2. Use variables for dynamic inputs to make scripts more versatile.

-

3. Consider potential errors and how to handle them.

-

4. Test your scripts thoroughly to ensure they work as expected.

-

5. Provide a clear and concise usage description to help others understand how to use your script.

-

6. Define the expected variables in the Variables field to document the script's requirements.

- -

Using the Description Field

-

The Description field allows you to provide a brief explanation of what SynthOS can use your script for.

- -

Defining Variables

-

Use the Variables field to specify any input parameters your script expects. Format it as a JSON object, e.g., { city: string, days?: number }. This helps SynthOS know what inputs it needs to provide when running your script.

-
-
-
-
-
- - - - - + + + + SynthOS - Scripts + + + + + + + + + + +
+ + + +
+ +
+
+
Page Builder
+
+

SynthOS: Add or modify scripts that can be executed using the /api/scripts/:id API.

+
+
+ +
+
+
+
Script Editor
+
+
+
    + +
+ +
+
+
+ Select an existing script to edit or add a new one using the "Add New Script" button. +
+ + + + + +
Script Command is required.
+ + +
+

Understanding SynthOS Scripts

+

Scripts in SynthOS are powerful tools that allow you to define custom terminal commands for various tasks. When SynthOS executes a script, it runs the command and captures the console output, which is then returned for further processing or analysis.

+ +

Creating Effective Scripts

+

When writing a script, you can use any valid terminal command. For added flexibility, you can include {{variable}} placeholders, which SynthOS will replace with actual values during execution.

+ +

Example: Weather Forecast Script

+

Here's an example of an interesting script that SynthOS could use:

+

curl wttr.in/{{city}}?format=3

+

This script fetches a concise weather forecast for a specified city. SynthOS can call this script with different city names to get up-to-date weather information.

+ +

Tips for Script Writing

+

1. Keep commands concise and focused on a single task.

+

2. Use variables for dynamic inputs to make scripts more versatile.

+

3. Consider potential errors and how to handle them.

+

4. Test your scripts thoroughly to ensure they work as expected.

+

5. Provide a clear and concise usage description to help others understand how to use your script.

+

6. Define the expected variables in the Variables field to document the script's requirements.

+ +

Using the Description Field

+

The Description field allows you to provide a brief explanation of what SynthOS can use your script for.

+ +

Defining Variables

+

Use the Variables field to specify any input parameters your script expects. Format it as a JSON object, e.g., { city: string, days?: number }. This helps SynthOS know what inputs it needs to provide when running your script.

+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/required-pages/synthos_scripts/page.json b/required-pages/synthos_scripts/page.json new file mode 100644 index 0000000..fa5b531 --- /dev/null +++ b/required-pages/synthos_scripts/page.json @@ -0,0 +1,10 @@ +{ + "title": "SynthOS Scripts", + "categories": [ + "System" + ], + "pinned": false, + "showInAll": false, + "pageVersion": 3, + "mode": "locked" +} diff --git a/src/agents/index.ts b/src/agents/index.ts index 106378e..0773b70 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -1,6 +1,6 @@ export { AgentConfig, AgentResponse, AgentEvent, AgentProvider, Attachment, ChatMessage } from './types'; export { a2aProvider } from './a2a/a2aProvider'; export { openclawProvider } from './openclaw/openclawProvider'; -export { connectAgent, disconnectAgent, getAgentStatus } from './openclaw/gatewayManager'; +export { connectAgent, disconnectAgent, getAgentStatus, setOpenClawDebug } from './openclaw/gatewayManager'; export { startTunnel, stopTunnel, getTunnelStatus } from './openclaw/sshTunnelManager'; export { discoverA2AAgent, discoverOpenClawAgent } from './discovery'; diff --git a/src/agents/openclaw/gatewayManager.ts b/src/agents/openclaw/gatewayManager.ts index cdff07c..087b039 100644 --- a/src/agents/openclaw/gatewayManager.ts +++ b/src/agents/openclaw/gatewayManager.ts @@ -1,4 +1,4 @@ -import { startTunnel, stopTunnel, SshTunnelConfig } from './sshTunnelManager'; +import { startTunnel, stopTunnel, setSshTunnelDebug, SshTunnelConfig } from './sshTunnelManager'; // --------------------------------------------------------------------------- // Types @@ -52,6 +52,14 @@ const BASE_RECONNECT_DELAY_MS = 1_000; // Module-level connection pool // --------------------------------------------------------------------------- +let _debug = false; + +/** Enable or disable OpenClaw + SSH tunnel debug logging. */ +export function setOpenClawDebug(enabled: boolean): void { + _debug = enabled; + setSshTunnelDebug(enabled); +} + const connections = new Map(); let _nextId = 1; @@ -162,7 +170,7 @@ export async function connect(config: GatewayConfig): Promise ws.addEventListener('error', () => { // The close event will fire after this, so we don't settle here. // Just log for diagnostics. - console.error(`[OpenClaw] WebSocket error on gateway ${config.name}`); + if (_debug) console.error(`[OpenClaw] WebSocket error on gateway ${config.name}`); }); } catch (err) { settle(err instanceof Error ? err : new Error(String(err))); @@ -242,7 +250,7 @@ export function request(conn: GatewayConnection, method: string, params?: unknow msg.params = params; } - console.log(`[OpenClaw] → req: ${id} ${method}`); + if (_debug) console.log(`[OpenClaw] → req: ${id} ${method}`); conn.ws.send(JSON.stringify(msg)); }); } @@ -369,7 +377,7 @@ function handleMessage( // Skip noisy per-token agent events if (eventName === 'agent') { // Still dispatch to listeners below, just don't log - } else { + } else if (_debug) { console.log(`[OpenClaw] ← event: ${eventName}`); if (eventName === 'chat') { console.log(`[OpenClaw] ← ${eventName} payload:\n${JSON.stringify(msg.payload, null, 2)}`); @@ -386,7 +394,7 @@ function handleMessage( if (listeners) { for (const listener of listeners) { try { listener(msg.payload); } catch (err) { - console.error(`[OpenClaw] Event listener error (${eventName}):`, err); + if (_debug) console.error(`[OpenClaw] Event listener error (${eventName}):`, err); } } } @@ -395,7 +403,7 @@ function handleMessage( if (wildcardListeners) { for (const listener of wildcardListeners) { try { listener(msg); } catch (err) { - console.error(`[OpenClaw] Wildcard listener error:`, err); + if (_debug) console.error(`[OpenClaw] Wildcard listener error:`, err); } } } @@ -410,10 +418,10 @@ function handleMessage( const error = msg.error as Record | string | undefined; const payloadType = (payload as Record).type as string | undefined; - console.log(`[OpenClaw] ← res: ${id} ${ok ? (payloadType ?? 'ok') : 'ERROR'}`); + if (_debug) console.log(`[OpenClaw] ← res: ${id} ${ok ? (payloadType ?? 'ok') : 'ERROR'}`); if (!ok || error) { - console.error(`[OpenClaw] ← error (id: ${id}):\n${JSON.stringify(msg, null, 2)}`); + if (_debug) console.error(`[OpenClaw] ← error (id: ${id}):\n${JSON.stringify(msg, null, 2)}`); } // Check if this is the hello-ok response to our connect request @@ -513,7 +521,7 @@ function sendConnectRequest(conn: GatewayConnection): void { conn.pendingRequests.set(id, { resolve: () => { /* handled in handleMessage via hello-ok check */ }, reject: (err: Error) => { - console.error(`[OpenClaw] Connect request failed for ${conn.config.name}: ${err.message}`); + if (_debug) console.error(`[OpenClaw] Connect request failed for ${conn.config.name}: ${err.message}`); }, timer: setTimeout(() => { conn.pendingRequests.delete(id); @@ -556,7 +564,7 @@ function scheduleReconnect(conn: GatewayConnection): void { try { await connect(conn.config); } catch (err) { - console.error(`[OpenClaw] Reconnect failed for ${conn.config.name}:`, err instanceof Error ? err.message : err); + if (_debug) console.error(`[OpenClaw] Reconnect failed for ${conn.config.name}:`, err instanceof Error ? err.message : err); } }, delay); } diff --git a/src/agents/openclaw/openclawProvider.ts b/src/agents/openclaw/openclawProvider.ts index a224d6f..05467b1 100644 --- a/src/agents/openclaw/openclawProvider.ts +++ b/src/agents/openclaw/openclawProvider.ts @@ -248,14 +248,12 @@ export const openclawProvider: AgentProvider = { async abortChat(agent: AgentConfig): Promise { const conn = await getConnection(agent); const sessionKey = await resolveSessionKey(agent, conn); - const result = await request(conn, 'chat.abort', { sessionKey }); - console.log('[OpenClaw] ← raw chat.abort:', JSON.stringify(result, null, 2)); + await request(conn, 'chat.abort', { sessionKey }); }, async clearSession(agent: AgentConfig): Promise { const conn = await getConnection(agent); const sessionKey = await resolveSessionKey(agent, conn); - const result = await request(conn, 'sessions.reset', { sessionKey }); - console.log('[OpenClaw] ← raw sessions.reset:', JSON.stringify(result, null, 2)); + await request(conn, 'sessions.reset', { sessionKey }); }, }; diff --git a/src/agents/openclaw/sshTunnelManager.ts b/src/agents/openclaw/sshTunnelManager.ts index ba8fd61..11ace8c 100644 --- a/src/agents/openclaw/sshTunnelManager.ts +++ b/src/agents/openclaw/sshTunnelManager.ts @@ -37,6 +37,13 @@ const MAX_RECONNECT_DELAY_MS = 120_000; const IS_WINDOWS = process.platform === 'win32'; +let _debug = false; + +/** Enable or disable SSH tunnel debug logging. */ +export function setSshTunnelDebug(enabled: boolean): void { + _debug = enabled; +} + // --------------------------------------------------------------------------- // Shared askpass script — reads password from an env var, never touches disk // with the actual secret. One script is created per process lifetime and @@ -144,7 +151,7 @@ export function stopTunnel(agentId: string): void { entry.running = false; entry.reconnecting = false; tunnels.delete(agentId); - console.log(`[SSH Tunnel] Stopped tunnel for agent "${agentId}"`); + if (_debug) console.log(`[SSH Tunnel] Stopped tunnel for agent "${agentId}"`); } /** @@ -206,7 +213,7 @@ function probePort(port: number, timeoutMs: number = 1000): Promise { */ function spawnTunnel(entry: TunnelEntry): Promise { return new Promise((resolve, reject) => { - console.log(`[SSH Tunnel] Starting tunnel for agent "${entry.agentId}": ${entry.config.command}`); + if (_debug) console.log(`[SSH Tunnel] Starting tunnel for agent "${entry.agentId}": ${entry.config.command}`); let settled = false; @@ -247,7 +254,7 @@ function spawnTunnel(entry: TunnelEntry): Promise { env.DISPLAY = env.DISPLAY || 'dummy:0'; } - console.log(`[SSH Tunnel] Spawning: ${cmd} ${args.join(' ')}`); + if (_debug) console.log(`[SSH Tunnel] Spawning: ${cmd} ${args.join(' ')}`); const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'], @@ -260,6 +267,7 @@ function spawnTunnel(entry: TunnelEntry): Promise { // Log all output for diagnostics const handleOutput = (source: string) => (data: Buffer) => { + if (!_debug) return; const text = data.toString().trim(); if (text) { console.log(`[SSH Tunnel] [${entry.agentId}] ${source}: ${text}`); @@ -270,7 +278,7 @@ function spawnTunnel(entry: TunnelEntry): Promise { child.stderr?.on('data', handleOutput('stderr')); child.on('error', (err) => { - console.error(`[SSH Tunnel] Spawn error for agent "${entry.agentId}":`, err.message); + if (_debug) console.error(`[SSH Tunnel] Spawn error for agent "${entry.agentId}":`, err.message); entry.running = false; entry.process = null; settle(err); @@ -281,7 +289,7 @@ function spawnTunnel(entry: TunnelEntry): Promise { entry.running = false; entry.process = null; - console.log(`[SSH Tunnel] Process exited for agent "${entry.agentId}" (code=${code}, signal=${signal})`); + if (_debug) console.log(`[SSH Tunnel] Process exited for agent "${entry.agentId}" (code=${code}, signal=${signal})`); // If we never settled (tunnel never came up), reject settle(new Error(`SSH tunnel exited before becoming ready (code=${code})`)); @@ -303,7 +311,7 @@ function spawnTunnel(entry: TunnelEntry): Promise { entry.running = true; entry.reconnecting = false; entry.reconnectAttempts = 0; - console.log(`[SSH Tunnel] Tunnel ready (delay heuristic) for agent "${entry.agentId}"`); + if (_debug) console.log(`[SSH Tunnel] Tunnel ready (delay heuristic) for agent "${entry.agentId}"`); settle(); } }, TUNNEL_READY_PROBE_DELAY_MS); @@ -336,7 +344,7 @@ function waitForPort(entry: TunnelEntry, port: number, settle: (err?: Error) => entry.running = true; entry.reconnecting = false; entry.reconnectAttempts = 0; - console.log(`[SSH Tunnel] Tunnel ready (port ${port} open) for agent "${entry.agentId}"`); + if (_debug) console.log(`[SSH Tunnel] Tunnel ready (port ${port} open) for agent "${entry.agentId}"`); settle(); } else if (attempts >= maxAttempts) { clearInterval(timer); @@ -344,7 +352,7 @@ function waitForPort(entry: TunnelEntry, port: number, settle: (err?: Error) => entry.running = true; entry.reconnecting = false; entry.reconnectAttempts = 0; - console.log(`[SSH Tunnel] Tunnel assumed ready (port probe timed out) for agent "${entry.agentId}"`); + if (_debug) console.log(`[SSH Tunnel] Tunnel assumed ready (port probe timed out) for agent "${entry.agentId}"`); settle(); } }, intervalMs); @@ -364,7 +372,7 @@ function scheduleReconnect(entry: TunnelEntry): void { MAX_RECONNECT_DELAY_MS ); - console.log(`[SSH Tunnel] Scheduling reconnect for agent "${entry.agentId}" in ${delay}ms (attempt ${entry.reconnectAttempts})`); + if (_debug) console.log(`[SSH Tunnel] Scheduling reconnect for agent "${entry.agentId}" in ${delay}ms (attempt ${entry.reconnectAttempts})`); entry.reconnectTimer = setTimeout(async () => { entry.reconnectTimer = null; @@ -372,9 +380,9 @@ function scheduleReconnect(entry: TunnelEntry): void { try { await spawnTunnel(entry); - console.log(`[SSH Tunnel] Reconnected tunnel for agent "${entry.agentId}"`); + if (_debug) console.log(`[SSH Tunnel] Reconnected tunnel for agent "${entry.agentId}"`); } catch (err) { - console.error(`[SSH Tunnel] Reconnect failed for agent "${entry.agentId}":`, + if (_debug) console.error(`[SSH Tunnel] Reconnect failed for agent "${entry.agentId}":`, err instanceof Error ? err.message : err); // Schedule another attempt if (!entry.intentionalStop) { diff --git a/src/builders/anthropic.ts b/src/builders/anthropic.ts index aa39512..4586bee 100644 --- a/src/builders/anthropic.ts +++ b/src/builders/anthropic.ts @@ -1,6 +1,6 @@ import { anthropic as createAnthropicModel, completePrompt, ContentBlock } from '../models'; import { parseChangeList, getTransformInstr } from '../service/transformPage'; -import { Attachment, Builder, BuilderResult, CHANGE_OPS_SCHEMA, ContextSection } from './types'; +import { Attachment, Builder, BuilderResult, CHANGE_OPS_SCHEMA, CHANGE_OPS_SCHEMA_NO_RANGED, ContextSection } from './types'; // --------------------------------------------------------------------------- // Builder options — passed from the route handler @@ -11,13 +11,17 @@ export interface AnthropicBuilderOptions { model?: string; /** Optional wrapper applied to any internally-created model (e.g. for debug logging). */ wrapModel?: (model: completePrompt) => completePrompt; + /** When true, this is the first edit to a saved page (v0→v1) — forces Opus + no ranged writes. */ + isFirstEdit?: boolean; + /** When true, retry the last edit — forces Opus + no ranged writes, skips classification. */ + tryAgain?: boolean; } // --------------------------------------------------------------------------- // Request classification // --------------------------------------------------------------------------- -export type Classification = 'hard-change' | 'easy-change' | 'question'; +export type Classification = 'question' | 'easy-change' | 'medium-change' | 'hard-change' | 're-write'; export interface ClassifyResult { classification: Classification; @@ -28,21 +32,20 @@ export interface ClassifyResult { const CLASSIFIER_SYSTEM_PROMPT = `You classify user messages for a web page builder. Default to a change request. Only classify as "question" when the user is purely asking for information with zero implication that anything should change. -Step 1 — Does the message describe a problem, bug, broken behavior, or something that should be different? - Yes → it is a change request (the user wants it fixed). Go to step 2. - No → go to step 3. - -Step 2 — How complex is the change? - Simple (text edits, color/style changes, adding/removing a single element, toggling visibility, minor CSS tweaks) → "easy-change" - Complex (new features, games, animations, restructuring components, significant JS logic, forms with validation, multi-step work) → "hard-change" - -Step 3 — Is the message a direct, explicit question asking for information only? Examples: "What color is the header?", "How many sections are there?", "What font is the title using?" +Step 1 — Is this a pure information question with zero change implication? + Examples: "What color is the header?", "How many sections are there?", "What font is the title using?" Yes, and there is absolutely no suggestion that anything should change → "question" - Otherwise → treat as a change request, go to step 2. + No → go to step 2. + +Step 2 — Classify complexity: + "easy-change": Text edits, color/style tweaks, toggling visibility, adding/removing a single element, minor CSS changes. + "medium-change": Modifying existing JS logic, updating event handlers, changing multiple related styles, adding a small feature to existing functionality, updating existing behavior. + "hard-change": Complex new features, significant new JS, games, animations, restructuring layout, forms with validation, multi-step work. + "re-write": Complete page overhaul, fundamental restructure, "start over", "rebuild", "redo the whole page". Return only JSON. No other text. -- Change: { "classification": "easy-change" } or { "classification": "hard-change" } +- Change: { "classification": "easy-change" } or { "classification": "medium-change" } or { "classification": "hard-change" } or { "classification": "re-write" } - Question: { "classification": "question", "answer": "" }`; export async function classifyRequest( @@ -54,7 +57,7 @@ export async function classifyRequest( const sonnet = createAnthropicModel({ apiKey, model: 'claude-sonnet-4-5' }); const result = await sonnet({ system: { role: 'system', content: CLASSIFIER_SYSTEM_PROMPT }, - prompt: { role: 'user', content: `\n${pageHtml}\n\n\n\n${userMessage}\n` }, + prompt: { role: 'user', content: `\n${pageHtml}\n\n\n${userMessage}` }, jsonMode: true, }); @@ -67,7 +70,7 @@ export async function classifyRequest( if (c === 'question') { return { classification: 'question', answer: typeof parsed.answer === 'string' ? parsed.answer : '' }; } - if (c === 'easy-change' || c === 'hard-change') { + if (c === 'easy-change' || c === 'medium-change' || c === 'hard-change' || c === 're-write') { return { classification: c }; } return { classification: 'hard-change' }; @@ -100,41 +103,64 @@ export function createAnthropicBuilder( async run(currentPage, additionalSections, userMessage, newBuild, attachments?): Promise { try { const isOpus = options?.model?.startsWith('claude-opus-'); + const noRanged = CHANGE_OPS_SCHEMA_NO_RANGED; // Non-Opus models or missing apiKey: existing behavior if (!isOpus || !options?.apiKey) { return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name, attachments); } - // Console errors bypass classification — always route to Opus + // Console errors bypass classification — always route to Opus with no ranged writes if (userMessage.includes('CONSOLE_ERRORS:')) { - console.log('classifyRequest: console errors detected → routing to ' + options.model!); - return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name, attachments); + console.log('classifyRequest: console errors detected → routing to ' + options.model! + ' (no ranged)'); + return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name, attachments, noRanged); + } + + // New builds always use Opus with no ranged writes + if (newBuild) { + console.log('classifyRequest: new build → routing to ' + options.model! + ' (no ranged)'); + return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name, attachments, noRanged); + } + + // First edit to a saved page (v0→v1) — force Opus with no ranged writes + if (options?.isFirstEdit) { + console.log('classifyRequest: first edit (v0→v1) → routing to ' + options.model! + ' (no ranged)'); + return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name, attachments, noRanged); + } + + // Try again — force Opus with no ranged writes, skip classification + if (options?.tryAgain) { + console.log('classifyRequest: try again → routing to claude-opus-4-6 (no ranged)'); + let opus: completePrompt = createAnthropicModel({ apiKey: options.apiKey!, model: 'claude-opus-4-6' }); + if (options.wrapModel) opus = options.wrapModel(opus); + return buildWithModel(opus, currentPage, additionalSections, userMessage, userInstructions, name, attachments, noRanged); } // Classify the request using Sonnet const classifyResult = await classifyRequest(options.apiKey, currentPage.content, userMessage); - console.log(`classifyRequest: "${classifyResult.classification}" → routing to ${routeLabel(classifyResult.classification, newBuild, options.model!)}`); + console.log(`classifyRequest: "${classifyResult.classification}" → routing to ${routeLabel(classifyResult.classification, options.model!)}`); // Questions — answer was already provided by the classifier if (classifyResult.classification === 'question') { return { kind: 'reply', text: classifyResult.answer ?? '' }; } - // New builds always use Opus (the configured model) - if (newBuild) { - return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name, attachments); - } - - // Easy changes use Sonnet + // Easy changes use Sonnet with default schema (ranged allowed) if (classifyResult.classification === 'easy-change') { let sonnet: completePrompt = createAnthropicModel({ apiKey: options.apiKey, model: 'claude-sonnet-4-5' }); if (options.wrapModel) sonnet = options.wrapModel(sonnet); return buildWithModel(sonnet, currentPage, additionalSections, userMessage, userInstructions, name, attachments); } - // Hard changes use Opus - return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name, attachments); + // Medium changes use Sonnet with no ranged writes + if (classifyResult.classification === 'medium-change') { + let sonnet: completePrompt = createAnthropicModel({ apiKey: options.apiKey, model: 'claude-sonnet-4-5' }); + if (options.wrapModel) sonnet = options.wrapModel(sonnet); + return buildWithModel(sonnet, currentPage, additionalSections, userMessage, userInstructions, name, attachments, noRanged); + } + + // Hard changes and re-writes use Opus with no ranged writes + return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name, attachments, noRanged); } catch (err: unknown) { return { kind: 'error', error: err instanceof Error ? err : new Error(String(err)) }; } @@ -153,7 +179,8 @@ export async function buildWithModel( userMessage: string, userInstructions: string | undefined, productName: string, - attachments?: Attachment[] + attachments?: Attachment[], + outputSchema?: Record ): Promise { // -- System message: all static content (cacheable) -- const systemParts: string[] = []; @@ -199,7 +226,7 @@ export async function buildWithModel( system: { role: 'system', content: systemContent }, prompt: { role: 'user', content: promptContent }, cacheSystem: true, - outputSchema: CHANGE_OPS_SCHEMA, + outputSchema: outputSchema ?? CHANGE_OPS_SCHEMA, }); if (!result.completed) { @@ -214,11 +241,12 @@ export async function buildWithModel( // Route label for console logging // --------------------------------------------------------------------------- -function routeLabel(classification: Classification, newBuild: boolean, configuredModel: string): string { +function routeLabel(classification: Classification, configuredModel: string): string { if (classification === 'question') return 'classifier (answered inline)'; - if (newBuild) return configuredModel; if (classification === 'easy-change') return 'claude-sonnet-4-5'; - return configuredModel; + if (classification === 'medium-change') return 'claude-sonnet-4-5 (no ranged)'; + if (classification === 're-write') return configuredModel + ' (re-write)'; + return configuredModel + ' (no ranged)'; } // --------------------------------------------------------------------------- diff --git a/src/builders/types.ts b/src/builders/types.ts index 2cea2c6..243c156 100644 --- a/src/builders/types.ts +++ b/src/builders/types.ts @@ -26,20 +26,17 @@ Each operation must be one of: { "op": "style-element", "nodeId": "", "style": "" } — sets the style attribute of the target element (must be unlocked) -{ "op": "update-lines", "nodeId": "", "startLine": , "endLine": , "content": "" } - — replaces lines startLine..endLine (inclusive, 1-based) in a script/style block +{ "op": "search-replace", "nodeId": "", "search": "", "replace": "" } + — finds exact text within a script/style block and replaces it. Use empty string for replace to delete. -{ "op": "delete-lines", "nodeId": "", "startLine": , "endLine": } - — removes lines startLine..endLine (inclusive, 1-based) from a script/style block +{ "op": "search-insert", "nodeId": "", "after": "", "content": "" } + — inserts new content immediately after the matched text in a script/style block. -{ "op": "insert-lines", "nodeId": "", "afterLine": , "content": "" } - — inserts lines after line n (1-based; 0 = before first line) in a script/style block - -Script and style blocks have line numbers prefixed (e.g. "01: let x = 1;"). Use these for -line-range ops. Do not include line number prefixes in your content. For small edits to large -scripts/styles, prefer update-lines/delete-lines/insert-lines over update to reduce output. -When using multiple line-range ops on the same block, apply from bottom to top (highest line -numbers first) to avoid line drift. +For partial edits to large scripts/styles, use search-replace or search-insert instead of +replacing the entire block with update. +Copy the search/after text exactly as it appears in the source. +When making multiple edits to the same block, ensure each search string targets distinct text. +To delete code, use search-replace with an empty replace string. Return ONLY the JSON array. Example: [ @@ -112,41 +109,98 @@ export const CHANGE_OPS_SCHEMA: Record = { { type: 'object', properties: { - op: { type: 'string', const: 'update-lines' }, + op: { type: 'string', const: 'search-replace' }, + nodeId: { type: 'string' }, + search: { type: 'string' }, + replace: { type: 'string' }, + }, + required: ['op', 'nodeId', 'search', 'replace'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + op: { type: 'string', const: 'search-insert' }, nodeId: { type: 'string' }, - startLine: { type: 'integer' }, - endLine: { type: 'integer' }, + after: { type: 'string' }, content: { type: 'string' }, }, - required: ['op', 'nodeId', 'startLine', 'endLine', 'content'], + required: ['op', 'nodeId', 'after', 'content'], additionalProperties: false, }, + ], + }, +}; + +/** + * Variant of CHANGE_OPS_SCHEMA that omits ranged write operations (search-replace, search-insert). + * Used for medium/hard/re-write changes where the model should replace full blocks instead of + * attempting partial edits. + */ +export const CHANGE_OPS_SCHEMA_NO_RANGED: Record = { + type: 'array', + items: { + anyOf: [ { type: 'object', properties: { - op: { type: 'string', const: 'delete-lines' }, + op: { type: 'string', const: 'update' }, nodeId: { type: 'string' }, - startLine: { type: 'integer' }, - endLine: { type: 'integer' }, + html: { type: 'string' }, }, - required: ['op', 'nodeId', 'startLine', 'endLine'], + required: ['op', 'nodeId', 'html'], additionalProperties: false, }, { type: 'object', properties: { - op: { type: 'string', const: 'insert-lines' }, + op: { type: 'string', const: 'replace' }, nodeId: { type: 'string' }, - afterLine: { type: 'integer' }, - content: { type: 'string' }, + html: { type: 'string' }, }, - required: ['op', 'nodeId', 'afterLine', 'content'], + required: ['op', 'nodeId', 'html'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + op: { type: 'string', const: 'delete' }, + nodeId: { type: 'string' }, + }, + required: ['op', 'nodeId'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + op: { type: 'string', const: 'insert' }, + parentId: { type: 'string' }, + position: { type: 'string', enum: ['prepend', 'append', 'before', 'after'] }, + html: { type: 'string' }, + }, + required: ['op', 'parentId', 'position', 'html'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + op: { type: 'string', const: 'style-element' }, + nodeId: { type: 'string' }, + style: { type: 'string' }, + }, + required: ['op', 'nodeId', 'style'], additionalProperties: false, }, ], }, }; +/** + * Text instruction variant for no-ranged-writes mode. + * Tells the model to use `update` for full innerHTML replacement instead of partial edits. + */ +export const CHANGE_OPS_FORMAT_INSTRUCTION_NO_RANGED = `For script and style blocks, use "update" to replace the full innerHTML or "replace" to replace the full outerHTML. Do NOT use partial search-replace edits.`; + /** * OpenAI structured outputs require a root-level object. * This wraps CHANGE_OPS_SCHEMA in { changes: [...] }. diff --git a/src/connectors/registry.ts b/src/connectors/registry.ts index f3ae531..8d880bf 100644 --- a/src/connectors/registry.ts +++ b/src/connectors/registry.ts @@ -12,19 +12,31 @@ function loadConnectorJson(dir: string): ConnectorDefinition | null { }; } -export function loadConnectorRegistry(connectorsDir: string): ConnectorDefinition[] { - if (!fs.existsSync(connectorsDir)) return []; - return fs.readdirSync(connectorsDir, { withFileTypes: true }) - .filter(d => d.isDirectory()) - .map(d => loadConnectorJson(path.join(connectorsDir, d.name))) - .filter((d): d is ConnectorDefinition => d !== null) - .sort((a, b) => a.name.localeCompare(b.name)); +export function loadConnectorRegistry(connectorsDirs: string[]): ConnectorDefinition[] { + const seen = new Set(); + const results: ConnectorDefinition[] = []; + for (const dir of connectorsDirs) { + if (!fs.existsSync(dir)) continue; + const entries = fs.readdirSync(dir, { withFileTypes: true }) + .filter(d => d.isDirectory()); + for (const d of entries) { + const def = loadConnectorJson(path.join(dir, d.name)); + if (def && !seen.has(def.id)) { + seen.add(def.id); + results.push(def); + } + } + } + return results.sort((a, b) => a.name.localeCompare(b.name)); } let _registry: ConnectorDefinition[] | undefined; -export function getConnectorRegistry(connectorsDir?: string): ConnectorDefinition[] { - if (!_registry || connectorsDir) { - _registry = loadConnectorRegistry(connectorsDir ?? path.join(__dirname, '../../service-connectors')); +let _registryKey: string | undefined; +export function getConnectorRegistry(connectorsDirs?: string[]): ConnectorDefinition[] { + const key = connectorsDirs ? connectorsDirs.join('|') : undefined; + if (!_registry || (connectorsDirs && key !== _registryKey)) { + _registry = loadConnectorRegistry(connectorsDirs ?? [path.join(__dirname, '../../service-connectors')]); + _registryKey = key; } return _registry; } diff --git a/src/customizer/Customizer.ts b/src/customizer/Customizer.ts index c4e42fe..2ef81aa 100644 --- a/src/customizer/Customizer.ts +++ b/src/customizer/Customizer.ts @@ -1,7 +1,6 @@ import { Application } from 'express'; import { SynthOSConfig } from '../init'; import { ContextSection } from '../builders/types'; -import path from 'path'; export type RouteInstaller = (config: SynthOSConfig, app: Application) => void; @@ -28,35 +27,39 @@ export class Customizer { // --- Content folder paths --- // Override these in a derived class to point to your fork's folders. + // Each getter returns `string[]`. The sentinel value `'default'` is + // resolved by `createConfig()` to the built-in SynthOS folder for that + // getter. Forks can return additional paths alongside (or instead of) + // `'default'` to layer custom folders. - /** Folder containing built-in system pages (builder, settings, etc.) */ - get requiredPagesFolder(): string { - return path.join(__dirname, '../../required-pages'); + /** Folders containing built-in system pages (builder, settings, etc.) */ + get requiredPagesFolders(): string[] { + return ['default']; } - /** Folder containing starter page templates copied on init */ - get defaultPagesFolder(): string { - return path.join(__dirname, '../../default-pages'); + /** Folders containing starter page templates copied on init */ + get defaultPagesFolders(): string[] { + return ['default']; } - /** Folder containing theme CSS/JSON files */ - get defaultThemesFolder(): string { - return path.join(__dirname, '../../default-themes'); + /** Folders containing theme CSS/JSON files */ + get defaultThemesFolders(): string[] { + return ['default']; } - /** Folder containing versioned page scripts (page-v2.js, etc.) */ - get pageScriptsFolder(): string { - return path.join(__dirname, '../../page-scripts'); + /** Folders containing versioned static files (page-v2.js, helpers-v2.js, etc.) */ + get staticFilesFolders(): string[] { + return ['default']; } - /** Folder containing connector JSON definitions */ - get serviceConnectorsFolder(): string { - return path.join(__dirname, '../../service-connectors'); + /** Folders containing connector JSON definitions */ + get serviceConnectorsFolders(): string[] { + return ['default']; } - /** Folder containing default scripts copied on init */ - get defaultScriptsFolder(): string { - return path.join(__dirname, '../../default-scripts'); + /** Folders containing default scripts copied on init */ + get defaultScriptsFolders(): string[] { + return ['default']; } /** Route path for the "browse all pages/tabs" listing page. diff --git a/src/files.ts b/src/files.ts index 819cbf1..402618c 100644 --- a/src/files.ts +++ b/src/files.ts @@ -72,3 +72,60 @@ export async function copyFolderRecursive(srcFolder: string, destFolder: string) export async function deleteFolder(dirPath: string): Promise { await fs.rm(dirPath, { recursive: true }); } + +// --- Multi-folder helpers --- + +/** + * Search folders in order, return the full path to the first existing match + * for the given filename. + */ +export async function findFileInFolders(folders: string[], filename: string): Promise { + for (const folder of folders) { + const candidate = path.join(folder, filename); + if (await checkIfExists(candidate)) { + return candidate; + } + } + return undefined; +} + +/** + * Merge file listings from multiple folders. First folder takes priority on + * name collisions (earlier occurrence wins). + */ +export async function listFilesFromFolders(folders: string[]): Promise { + const seen = new Set(); + const result: string[] = []; + for (const folder of folders) { + if (!await checkIfExists(folder)) continue; + const files = await listFiles(folder); + for (const f of files) { + if (!seen.has(f)) { + seen.add(f); + result.push(f); + } + } + } + return result; +} + +/** + * Copy files from multiple source folders into a single destination. + * First folder takes priority on duplicate filenames (copy is skipped + * if the file already exists in dest from an earlier folder). + */ +export async function copyFilesFromFolders(folders: string[], destFolder: string): Promise { + await ensureFolderExists(destFolder); + const copied = new Set(); + for (const folder of folders) { + if (!await checkIfExists(folder)) continue; + const files = await fs.readdir(folder); + for (const file of files) { + if (copied.has(file)) continue; + copied.add(file); + const srcPath = path.join(folder, file); + const destPath = path.join(destFolder, file); + await fs.copyFile(srcPath, destPath); + } + } +} diff --git a/src/init.ts b/src/init.ts index 0eb09de..af54725 100644 --- a/src/init.ts +++ b/src/init.ts @@ -1,41 +1,66 @@ import * as fs from 'fs/promises'; import path from "path"; -import { checkIfExists, copyFile, copyFiles, deleteFile, ensureFolderExists, listFiles, saveFile } from "./files"; +import { checkIfExists, copyFile, copyFolderRecursive, deleteFile, ensureFolderExists, findFileInFolders, listFiles, listFolders, saveFile } from "./files"; import { PAGE_VERSION, getRequiredPages } from "./pages"; import { DefaultSettings } from "./settings"; -import { getOutdatedThemes, parseThemeFilename } from "./themes"; import { Customizer } from './customizer'; export interface SynthOSConfig { localFolder: string; pagesFolder: string; - requiredPagesFolder: string; - defaultPagesFolder: string; - defaultScriptsFolder: string; - defaultThemesFolder: string; - pageScriptsFolder: string; - serviceConnectorsFolder: string; + requiredPagesFolders: string[]; + defaultPagesFolders: string[]; + defaultScriptsFolders: string[]; + defaultThemesFolders: string[]; + staticFilesFolders: string[]; + serviceConnectorsFolders: string[]; requiredPages: string[]; debug: boolean; debugPageUpdates: boolean; } +/** + * Resolve a folder array from a Customizer getter: replace the `'default'` + * sentinel with the built-in SynthOS path. + */ +function resolveFolders(folders: string[], builtInPath: string): string[] { + return folders.map(f => f === 'default' ? builtInPath : f); +} + export async function createConfig( pagesFolder = '.synthos', options?: { debug?: boolean; debugPageUpdates?: boolean }, customizer?: Customizer ): Promise { - const requiredPagesFolder = customizer?.requiredPagesFolder ?? path.join(__dirname, '../required-pages'); - const requiredPages = await getRequiredPages(requiredPagesFolder); + const requiredPagesFolders = resolveFolders( + customizer?.requiredPagesFolders ?? ['default'], + path.join(__dirname, '../required-pages') + ); + const requiredPages = await getRequiredPages(requiredPagesFolders); return { localFolder: pagesFolder, pagesFolder: path.join(process.cwd(), pagesFolder), - requiredPagesFolder, - defaultPagesFolder: customizer?.defaultPagesFolder ?? path.join(__dirname, '../default-pages'), - defaultScriptsFolder: customizer?.defaultScriptsFolder ?? path.join(__dirname, '../default-scripts'), - defaultThemesFolder: customizer?.defaultThemesFolder ?? path.join(__dirname, '../default-themes'), - pageScriptsFolder: customizer?.pageScriptsFolder ?? path.join(__dirname, '../page-scripts'), - serviceConnectorsFolder: customizer?.serviceConnectorsFolder ?? path.join(__dirname, '../service-connectors'), + requiredPagesFolders, + defaultPagesFolders: resolveFolders( + customizer?.defaultPagesFolders ?? ['default'], + path.join(__dirname, '../default-pages') + ), + defaultScriptsFolders: resolveFolders( + customizer?.defaultScriptsFolders ?? ['default'], + path.join(__dirname, '../default-scripts') + ), + defaultThemesFolders: resolveFolders( + customizer?.defaultThemesFolders ?? ['default'], + path.join(__dirname, '../default-themes') + ), + staticFilesFolders: resolveFolders( + customizer?.staticFilesFolders ?? ['default'], + path.join(__dirname, '../static-files') + ), + serviceConnectorsFolders: resolveFolders( + customizer?.serviceConnectorsFolders ?? ['default'], + path.join(__dirname, '../service-connectors') + ), requiredPages, debug: options?.debug ?? false, debugPageUpdates: options?.debugPageUpdates ?? false @@ -63,34 +88,26 @@ export async function init(config: SynthOSConfig, includeDefaultPages: boolean = console.log(`Copying default scripts to ${config.localFolder} folder...`); const scriptsFolder = path.join(config.pagesFolder, 'scripts'); await ensureFolderExists(scriptsFolder); - switch (process.platform) { - case 'win32': - await copyFile(path.join(config.defaultScriptsFolder, 'windows-terminal.json'), scriptsFolder); - break; - case 'darwin': - await copyFile(path.join(config.defaultScriptsFolder, 'mac-terminal.json'), scriptsFolder); - break; - case 'android': - await copyFile(path.join(config.defaultScriptsFolder, 'android-terminal.json'), scriptsFolder); - break; - case 'linux': - default: - await copyFile(path.join(config.defaultScriptsFolder, 'linux-terminal.json'), scriptsFolder); - break; - } + const scriptFilename = ({ + win32: 'windows-terminal.json', + darwin: 'mac-terminal.json', + android: 'android-terminal.json', + } as Record)[process.platform] ?? 'linux-terminal.json'; + const scriptSrc = await findFileInFolders(config.defaultScriptsFolders, scriptFilename); + if (scriptSrc) { + await copyFile(scriptSrc, scriptsFolder); + } await saveFile(path.join(scriptsFolder, 'example.sh'), `#!/bin/bash\n\n# This is an example script\n\n# You can run this script using the following command:\n# sh ${config.localFolder}/scripts/example.sh\n\n# This script will print "Hello, World!" to the console\n\necho "Hello, World!"\n`); - // Setup default themes - console.log(`Copying default themes to ${config.localFolder} folder...`); - const themesFolder = path.join(config.pagesFolder, 'themes'); - await ensureFolderExists(themesFolder); - await copyFiles(config.defaultThemesFolder, themesFolder); + // Create empty themes folder — default themes are served directly from + // defaultThemesFolders; users can add custom themes here. + await ensureFolderExists(path.join(config.pagesFolder, 'themes')); // Copy pages if (includeDefaultPages) { console.log(`Copying default pages to ${config.localFolder} folder...`); - await copyDefaultPages(config.defaultPagesFolder, config.pagesFolder); + await copyDefaultPages(config.defaultPagesFolders, config.pagesFolder); } return true; @@ -102,55 +119,21 @@ async function repairMissingFolders(config: SynthOSConfig): Promise { if (!await checkIfExists(scriptsFolder)) { console.log(`Restoring default scripts to ${config.localFolder} folder...`); await ensureFolderExists(scriptsFolder); - switch (process.platform) { - case 'win32': - await copyFile(path.join(config.defaultScriptsFolder, 'windows-terminal.json'), scriptsFolder); - break; - case 'darwin': - await copyFile(path.join(config.defaultScriptsFolder, 'mac-terminal.json'), scriptsFolder); - break; - case 'android': - await copyFile(path.join(config.defaultScriptsFolder, 'android-terminal.json'), scriptsFolder); - break; - case 'linux': - default: - await copyFile(path.join(config.defaultScriptsFolder, 'linux-terminal.json'), scriptsFolder); - break; + const scriptFilename = ({ + win32: 'windows-terminal.json', + darwin: 'mac-terminal.json', + android: 'android-terminal.json', + } as Record)[process.platform] ?? 'linux-terminal.json'; + const scriptSrc = await findFileInFolders(config.defaultScriptsFolders, scriptFilename); + if (scriptSrc) { + await copyFile(scriptSrc, scriptsFolder); } await saveFile(path.join(scriptsFolder, 'example.sh'), `#!/bin/bash\n\n# This is an example script\n\n# You can run this script using the following command:\n# sh ${config.localFolder}/scripts/example.sh\n\n# This script will print "Hello, World!" to the console\n\necho "Hello, World!"\n`); } - // Rebuild themes folder from defaults if missing - const themesFolder = path.join(config.pagesFolder, 'themes'); - if (!await checkIfExists(themesFolder)) { - console.log(`Restoring default themes to ${config.localFolder} folder...`); - await ensureFolderExists(themesFolder); - await copyFiles(config.defaultThemesFolder, themesFolder); - } else { - // Upgrade outdated themes — copy newer versioned CSS from defaults - const outdated = await getOutdatedThemes(config); - if (outdated.length > 0) { - console.log(`Upgrading ${outdated.length} theme(s): ${outdated.join(', ')}...`); - const defaultFiles = await listFiles(config.defaultThemesFolder); - for (const themeName of outdated) { - // Remove old versioned CSS files for this theme - const localFiles = await listFiles(themesFolder); - for (const f of localFiles) { - const parsed = parseThemeFilename(f); - if (parsed && parsed.name === themeName) { - await deleteFile(path.join(themesFolder, f)); - } - } - // Copy the new versioned CSS from defaults - for (const f of defaultFiles) { - const parsed = parseThemeFilename(f); - if (parsed && parsed.name === themeName) { - await copyFile(path.join(config.defaultThemesFolder, f), themesFolder); - } - } - } - } - } + // Ensure themes folder exists — default themes are served directly from + // defaultThemesFolders; this folder is for user-added custom themes only. + await ensureFolderExists(path.join(config.pagesFolder, 'themes')); // Ensure pages/ subfolder exists const pagesSubdir = path.join(config.pagesFolder, 'pages'); @@ -159,7 +142,7 @@ async function repairMissingFolders(config: SynthOSConfig): Promise { const htmlFiles = (await listFiles(config.pagesFolder)).filter(f => f.endsWith('.html')); if (htmlFiles.length === 0) { console.log(`Restoring default pages to ${config.localFolder}/pages/ folder...`); - await copyDefaultPages(config.defaultPagesFolder, config.pagesFolder); + await copyDefaultPages(config.defaultPagesFolders, config.pagesFolder); } else { await ensureFolderExists(pagesSubdir); } @@ -213,40 +196,58 @@ async function migrateFlatPages(pagesFolder: string, localFolder: string): Promi } } -async function copyDefaultPages(srcFolder: string, destFolder: string): Promise { +async function copyDefaultPages(srcFolders: string[], destFolder: string): Promise { const pagesDir = path.join(destFolder, 'pages'); await ensureFolderExists(pagesDir); - const files = await fs.readdir(srcFolder); const now = new Date().toISOString(); - for (const file of files) { - if (!file.endsWith('.html')) continue; - const pageName = file.replace(/\.html$/, ''); - const pageFolder = path.join(pagesDir, pageName); - await ensureFolderExists(pageFolder); - await fs.copyFile(path.join(srcFolder, file), path.join(pageFolder, 'page.html')); - - // Read companion .json metadata from source folder, fall back to defaults - let metadata: Record = {}; - const jsonPath = path.join(srcFolder, `${pageName}.json`); - if (await checkIfExists(jsonPath)) { - try { - const raw = await fs.readFile(jsonPath, 'utf-8'); - metadata = JSON.parse(raw); - } catch { - // use defaults + const seen = new Set(); + + for (const srcFolder of srcFolders) { + if (!await checkIfExists(srcFolder)) continue; + const dirs = await listFolders(srcFolder); + for (const dir of dirs) { + const srcPageDir = path.join(srcFolder, dir); + if (!await checkIfExists(path.join(srcPageDir, 'page.html'))) continue; + if (seen.has(dir)) continue; // first folder wins + seen.add(dir); + + const pageFolder = path.join(pagesDir, dir); + await ensureFolderExists(pageFolder); + await fs.copyFile(path.join(srcPageDir, 'page.html'), path.join(pageFolder, 'page.html')); + + // Read companion page.json metadata from source folder, fall back to defaults + let metadata: Record = {}; + const jsonPath = path.join(srcPageDir, 'page.json'); + if (await checkIfExists(jsonPath)) { + try { + const raw = await fs.readFile(jsonPath, 'utf-8'); + metadata = JSON.parse(raw); + } catch { + // use defaults + } + } + const fullMetadata = { + title: typeof metadata.title === 'string' ? metadata.title : '', + categories: Array.isArray(metadata.categories) ? metadata.categories : [], + pinned: typeof metadata.pinned === 'boolean' ? metadata.pinned : false, + showInAll: typeof metadata.showInAll === 'boolean' ? metadata.showInAll : true, + createdDate: now, + lastModified: now, + pageVersion: typeof metadata.pageVersion === 'number' ? metadata.pageVersion + : typeof metadata.uxVersion === 'number' ? metadata.uxVersion : PAGE_VERSION, + mode: metadata.mode === 'locked' ? 'locked' : 'unlocked', + }; + await saveFile(path.join(pageFolder, 'page.json'), JSON.stringify(fullMetadata, null, 4)); + + // Copy data subfolders (anything that isn't page.html/page.json) + const subEntries = await fs.readdir(srcPageDir, { withFileTypes: true }); + for (const entry of subEntries) { + if (!entry.isDirectory()) continue; + await copyFolderRecursive( + path.join(srcPageDir, entry.name), + path.join(pageFolder, entry.name) + ); } } - const fullMetadata = { - title: typeof metadata.title === 'string' ? metadata.title : '', - categories: Array.isArray(metadata.categories) ? metadata.categories : [], - pinned: typeof metadata.pinned === 'boolean' ? metadata.pinned : false, - showInAll: typeof metadata.showInAll === 'boolean' ? metadata.showInAll : true, - createdDate: now, - lastModified: now, - pageVersion: typeof metadata.pageVersion === 'number' ? metadata.pageVersion - : typeof metadata.uxVersion === 'number' ? metadata.uxVersion : PAGE_VERSION, - mode: metadata.mode === 'locked' ? 'locked' : 'unlocked', - }; - await saveFile(path.join(pageFolder, 'page.json'), JSON.stringify(fullMetadata, null, 4)); } } diff --git a/src/migrations.ts b/src/migrations.ts index b5d1a12..ae84df9 100644 --- a/src/migrations.ts +++ b/src/migrations.ts @@ -10,6 +10,7 @@ import { deduplicateInlineScripts } from './service/transformPage'; */ const migrations: Record Promise> = { 1: migrateV1toV2, + 2: migrateV2toV3, }; /** @@ -36,7 +37,6 @@ const SHARED_CSS_SELECTORS = [ '.chat-panel', '.chat-header', '.chat-messages', '.chat-message', '.chat-message p', '.chat-message p strong', '.chat-message p code', '.chat-message strong', '.chat-message pre', '.chat-message code', '.chat-message a', - '.link-group', '.link-group a', '.link-group a:hover', 'form', '.chat-input', '.chat-input:focus', '.chat-input::placeholder', '.chat-input:disabled', '.chat-submit', '.chat-submit:hover', '.chat-submit:active', '.chat-submit:disabled', @@ -80,14 +80,8 @@ const DEFAULT_CHAT_PANEL = `

Welcome! How can I help you?

-
- - +
`; @@ -125,6 +119,33 @@ async function migrateV1toV2(html: string, completePrompt: completePrompt): Prom return migrated; } +/** + * v2 -> v3: Cheerio-based migration (no LLM). + * - Removes .link-group div + * - Converts chat to ' + ); + } + + // 3. Remove .chat-submit button inside #chatForm (or anywhere) + $('button.chat-submit').remove(); + + // Run through postProcessV2 to ensure structural integrity + return postProcessV2($.html()); +} + /** * Cheerio-based post-processing to verify the LLM output meets v2 requirements. * Uses the original HTML as a fallback source for critical elements. @@ -146,8 +167,7 @@ export function postProcessV2(html: string, originalHtml?: string): string { // Append default form $('.chat-panel').append(`
- - +
`); } } else { diff --git a/src/pages.ts b/src/pages.ts index b72dad2..71c8f22 100644 --- a/src/pages.ts +++ b/src/pages.ts @@ -1,21 +1,28 @@ -import {checkIfExists, deleteFile, deleteFolder, ensureFolderExists, listFiles, listFolders, loadFile, saveFile} from './files'; +import {checkIfExists, copyFolderRecursive, deleteFile, deleteFolder, ensureFolderExists, listFiles, listFolders, loadFile, saveFile} from './files'; import path from 'path'; -// Page State Cache -const _pages: { [name: string]: string } = {}; - /** * Derive the list of required page names by scanning *.html files - * in the given requiredPagesFolder directory. + * across one or more requiredPages folders. */ -export async function getRequiredPages(requiredPagesFolder: string): Promise { - const files = await listFiles(requiredPagesFolder); - return files - .filter(f => f.endsWith('.html')) - .map(f => f.replace(/\.html$/, '')); +export async function getRequiredPages(requiredPagesFolders: string[]): Promise { + const result: string[] = []; + const seen = new Set(); + for (const folder of requiredPagesFolders) { + if (!await checkIfExists(folder)) continue; + const entries = await listFolders(folder); + for (const entry of entries) { + if (seen.has(entry)) continue; + if (await checkIfExists(path.join(folder, entry, 'page.html'))) { + seen.add(entry); + result.push(entry); + } + } + } + return result; } -export const PAGE_VERSION = 2; +export const PAGE_VERSION = 3; export interface PageInfo { name: string; @@ -31,7 +38,7 @@ export interface PageInfo { export type PageMetadata = Omit; -export async function loadPageMetadata(pagesFolder: string, name: string, fallbackFolder?: string): Promise { +export async function loadPageMetadata(pagesFolder: string, name: string, fallbackFolders?: string[]): Promise { // 1. Try user override: /pages//page.json const metadataPath = path.join(pagesFolder, 'pages', name, 'page.json'); if (await checkIfExists(metadataPath)) { @@ -44,16 +51,18 @@ export async function loadPageMetadata(pagesFolder: string, name: string, fallba } } - // 2. Try fallback: fallbackFolder/.json - if (fallbackFolder) { - const fallbackPath = path.join(fallbackFolder, `${name}.json`); - if (await checkIfExists(fallbackPath)) { - try { - const raw = await loadFile(fallbackPath); - const parsed = JSON.parse(raw); - return parseMetadata(parsed); - } catch { - // fall through + // 2. Try fallback folders: fallbackFolder//page.json + if (fallbackFolders) { + for (const folder of fallbackFolders) { + const candidate = path.join(folder, name, 'page.json'); + if (await checkIfExists(candidate)) { + try { + const raw = await loadFile(candidate); + const parsed = JSON.parse(raw); + return parseMetadata(parsed); + } catch { + // fall through + } } } } @@ -92,7 +101,7 @@ const DEFAULT_METADATA: PageMetadata = { mode: 'unlocked', }; -export async function listPages(pagesFolder: string, fallbackPagesFolder: string): Promise { +export async function listPages(pagesFolder: string, fallbackPagesFolders: string[]): Promise { const pageMap = new Map(); // Folder-based pages under pages/ subdirectory @@ -137,13 +146,15 @@ export async function listPages(pagesFolder: string, fallbackPagesFolder: string } } - // Add pages from fallback (required) pages folder - const fallbackFiles = (await listFiles(fallbackPagesFolder)).filter(file => file.endsWith('.html')); - for (const file of fallbackFiles) { - const name = file.replace(/\.html$/, ''); - if (!pageMap.has(name)) { - // System page not yet in map — check for user override, then fallback .json - const metadata = await loadPageMetadata(pagesFolder, name, fallbackPagesFolder); + // Add pages from fallback (required) pages folders + for (const folder of fallbackPagesFolders) { + if (!await checkIfExists(folder)) continue; + const dirs = await listFolders(folder); + for (const name of dirs) { + if (pageMap.has(name)) continue; + if (!await checkIfExists(path.join(folder, name, 'page.html'))) continue; + // System page not yet in map — check for user override, then fallback page.json + const metadata = await loadPageMetadata(pagesFolder, name, fallbackPagesFolders); pageMap.set(name, { name, title: metadata?.title ?? '', @@ -165,22 +176,27 @@ export async function listPages(pagesFolder: string, fallbackPagesFolder: string return entries; } -export async function loadPageState(pagesFolder: string, name: string, reset: boolean): Promise { - if (!_pages[name] || reset) { - // Try folder-based path under pages/ first, then fall back to flat file - const folderPath = path.join(pagesFolder, 'pages', name, 'page.html'); - const flatPath = path.join(pagesFolder, `${name}.html`); - - if (await checkIfExists(folderPath)) { - _pages[name] = await loadFile(folderPath); - } else if (await checkIfExists(flatPath)) { - _pages[name] = await loadFile(flatPath); - } else { - return undefined; - } +export async function loadPageState(pagesFolder: string, name: string): Promise { + // Check for working-state version files first + const latestVersion = await getLatestVersion(pagesFolder, name); + if (latestVersion > 0) { + const versionHtml = await loadPageVersion(pagesFolder, name, latestVersion); + if (versionHtml) return versionHtml; } - return _pages[name]; + // Fall back to saved baseline + const folderPath = path.join(pagesFolder, 'pages', name, 'page.html'); + const directFolderPath = path.join(pagesFolder, name, 'page.html'); + const flatPath = path.join(pagesFolder, `${name}.html`); + + if (await checkIfExists(folderPath)) { + return loadFile(folderPath); + } else if (await checkIfExists(directFolderPath)) { + return loadFile(directFolderPath); + } else if (await checkIfExists(flatPath)) { + return loadFile(flatPath); + } + return undefined; } export function normalizePageName(name: string|undefined): string|undefined { @@ -188,7 +204,6 @@ export function normalizePageName(name: string|undefined): string|undefined { } export async function savePageState(pagesFolder: string, name: string, content: string, title?: string, categories?: string[]): Promise { - _pages[name] = content; const pageFolder = path.join(pagesFolder, 'pages', name); await ensureFolderExists(pageFolder); await saveFile(path.join(pageFolder, 'page.html'), content); @@ -211,10 +226,6 @@ export async function savePageState(pagesFolder: string, name: string, content: } } -export function updatePageState(name: string, content: string): void { - _pages[name] = content; -} - export async function deletePage(pagesFolder: string, name: string): Promise { // Delete folder-based page: /pages// const folderPath = path.join(pagesFolder, 'pages', name); @@ -228,8 +239,65 @@ export async function deletePage(pagesFolder: string, name: string): Promise/pages//page.v.html + */ +export async function savePageVersion(pagesFolder: string, name: string, version: number, html: string): Promise { + const pageFolder = path.join(pagesFolder, 'pages', name); + await ensureFolderExists(pageFolder); + await saveFile(path.join(pageFolder, `page.v${version}.html`), html); +} + +/** + * Load a version snapshot (returns undefined if the file doesn't exist). + */ +export async function loadPageVersion(pagesFolder: string, name: string, version: number): Promise { + const filePath = path.join(pagesFolder, 'pages', name, `page.v${version}.html`); + if (!await checkIfExists(filePath)) return undefined; + return loadFile(filePath); +} + +/** + * Scan page.v*.html files and return the highest version number (0 if none). + */ +export async function getLatestVersion(pagesFolder: string, name: string): Promise { + const pageFolder = path.join(pagesFolder, 'pages', name); + if (!await checkIfExists(pageFolder)) return 0; + const files = await listFiles(pageFolder); + let max = 0; + for (const file of files) { + const match = file.match(/^page\.v(\d+)\.html$/); + if (match) { + const v = parseInt(match[1], 10); + if (v > max) max = v; + } + } + return max; +} + +/** + * Delete all page.v*.html version files for a page. + */ +export async function clearVersions(pagesFolder: string, name: string): Promise { + const pageFolder = path.join(pagesFolder, 'pages', name); + if (!await checkIfExists(pageFolder)) return; + const files = await listFiles(pageFolder); + for (const file of files) { + if (/^page\.v\d+\.html$/.test(file)) { + await deleteFile(path.join(pageFolder, file)); + } + } +} + +export interface CopyPageOptions { + copyTables?: boolean; // default false + copyFiles?: boolean; // default true } export async function copyPage( @@ -238,14 +306,36 @@ export async function copyPage( targetName: string, title: string, categories: string[], - requiredPagesFolder: string + requiredPagesFolders: string[], + options?: CopyPageOptions ): Promise { - // Load source HTML from user folder, then try required folder as fallback - let html = await loadPageState(pagesFolder, sourceName, true); - if (!html) { - const requiredPath = path.join(requiredPagesFolder, `${sourceName}.html`); - if (await checkIfExists(requiredPath)) { - html = await loadFile(requiredPath); + const copyTables = options?.copyTables ?? false; + const copyFiles = options?.copyFiles ?? true; + + // Resolve source page folder: user pages first, then required pages + let sourceFolder: string | undefined; + const userSourceFolder = path.join(pagesFolder, 'pages', sourceName); + if (await checkIfExists(path.join(userSourceFolder, 'page.html'))) { + sourceFolder = userSourceFolder; + } else { + for (const folder of requiredPagesFolders) { + const candidate = path.join(folder, sourceName); + if (await checkIfExists(path.join(candidate, 'page.html'))) { + sourceFolder = candidate; + break; + } + } + } + + // Load source HTML + let html: string | undefined; + if (sourceFolder) { + html = await loadFile(path.join(sourceFolder, 'page.html')); + } else { + // Try legacy flat file + const flatPath = path.join(pagesFolder, `${sourceName}.html`); + if (await checkIfExists(flatPath)) { + html = await loadFile(flatPath); } } @@ -269,4 +359,27 @@ export async function copyPage( mode: 'unlocked', }; await savePageMetadata(pagesFolder, targetName, metadata); + + // Copy additional content from source if a folder was resolved + if (sourceFolder) { + const targetFolder = path.join(pagesFolder, 'pages', targetName); + + if (copyTables) { + const entries = await listFolders(sourceFolder); + for (const entry of entries) { + if (entry === 'files') continue; // handled separately + await copyFolderRecursive( + path.join(sourceFolder, entry), + path.join(targetFolder, entry) + ); + } + } + + if (copyFiles) { + const filesDir = path.join(sourceFolder, 'files'); + if (await checkIfExists(filesDir)) { + await copyFolderRecursive(filesDir, path.join(targetFolder, 'files')); + } + } + } } diff --git a/src/service/server.ts b/src/service/server.ts index a4c2b10..8efbc49 100644 --- a/src/service/server.ts +++ b/src/service/server.ts @@ -3,6 +3,9 @@ import { usePageRoutes } from './usePageRoutes'; import { useApiRoutes } from './useApiRoutes'; import { SynthOSConfig } from '../init'; import { useDataRoutes } from './useDataRoutes'; +import { useFileRoutes } from './useFileRoutes'; +import { useSharedDataRoutes } from './useSharedDataRoutes'; +import { useSharedFileRoutes } from './useSharedFileRoutes'; import { useConnectorRoutes } from './useConnectorRoutes'; import { useAgentRoutes } from './useAgentRoutes'; import { cyan, yellow, formatTime } from './debugLog'; @@ -29,6 +32,15 @@ export function server(config: SynthOSConfig, customizer: Customizer = defaultCu // Middleware to parse JSON data (10 MB limit for image attachments) app.use(express.json({ limit: '10mb' })); + // Favicon — serve from static files + app.get('/favicon.ico', (_req, res) => res.redirect('/static/favicon.svg')); + app.get('/favicon.svg', (_req, res) => res.redirect('/static/favicon.svg')); + + // Serve static files from staticFilesFolders (first folder wins) + for (const folder of config.staticFilesFolders) { + app.use('/static', express.static(folder, { maxAge: '1h' })); + } + // Page handling routes if (customizer.isEnabled('pages')) usePageRoutes(config, app, customizer); @@ -44,6 +56,15 @@ export function server(config: SynthOSConfig, customizer: Customizer = defaultCu // Data routes if (customizer.isEnabled('data')) useDataRoutes(config, app); + // File routes + if (customizer.isEnabled('files')) useFileRoutes(config, app); + + // Shared data routes + if (customizer.isEnabled('shared-data')) useSharedDataRoutes(config, app); + + // Shared file routes + if (customizer.isEnabled('shared-files')) useSharedFileRoutes(config, app); + // Custom routes from the Customizer for (const installer of customizer.getExtraRoutes()) { installer(config, app); diff --git a/src/service/transformPage.ts b/src/service/transformPage.ts index a9a0101..9b5717a 100644 --- a/src/service/transformPage.ts +++ b/src/service/transformPage.ts @@ -27,9 +27,8 @@ export type ChangeOp = | { op: "delete"; nodeId: string } | { op: "insert"; parentId: string; position: "prepend" | "append" | "before" | "after"; html: string } | { op: "style-element"; nodeId: string; style: string } - | { op: "update-lines"; nodeId: string; startLine: number; endLine: number; content: string } - | { op: "delete-lines"; nodeId: string; startLine: number; endLine: number } - | { op: "insert-lines"; nodeId: string; afterLine: number; content: string }; + | { op: "search-replace"; nodeId: string; search: string; replace: string } + | { op: "search-insert"; nodeId: string; after: string; content: string }; export type ChangeList = ChangeOp[]; @@ -51,62 +50,56 @@ export async function transformPage(args: TransformPageArgs): Promise', - content: numberedHtml, + content: annotatedHtml, instructions: '', }; - // 4. Determine newBuild: if isBuilder, count .chat-message in annotated HTML + // 3. Determine newBuild: if isBuilder, count .chat-message in annotated HTML let newBuild = false; if (args.isBuilder) { - const $ = cheerio.load(numberedHtml, { decodeEntities: false }); + const $ = cheerio.load(annotatedHtml, { decodeEntities: false }); const messageCount = $('#chatMessages .chat-message').length; newBuild = messageCount <= 1; } - // 5. Call builder + // 4. Call builder const result = await builder.run(currentPage, additionalSections, message, newBuild, args.attachments); - // 6. Switch on result kind + // 5. Switch on result kind switch (result.kind) { case 'transforms': { - const applied = applyChangeList(numberedHtml, result.changes); - const stripped = stripLineNumbers(applied); - const clean = stripNodeIds(stripped); + const applied = applyChangeList(annotatedHtml, result.changes); + const clean = stripNodeIds(applied); const deduped = deduplicateInlineScripts(clean); const safe = ensureScriptsBeforeBodyClose(deduped); return { completed: true, value: { html: safe, changeCount: result.changes.length } }; } case 'reply': { const productName = args.productName ?? 'SynthOS'; - const withReply = appendChatReply(numberedHtml, message, result.text, productName); - const stripped = stripLineNumbers(withReply); - const clean = stripNodeIds(stripped); + const withReply = appendChatReply(annotatedHtml, message, result.text, productName); + const clean = stripNodeIds(withReply); const deduped = deduplicateInlineScripts(clean); const safe = ensureScriptsBeforeBodyClose(deduped); - return { completed: true, value: { html: safe, changeCount: 0 } }; + return { completed: true, value: { html: safe, changeCount: -1 } }; } case 'error': { - const stripped = stripLineNumbers(numberedHtml); - const cleanOriginal = stripNodeIds(stripped); - const errorMessage = result.error.message; - const errorHtml = injectError(cleanOriginal, 'Something went wrong try again', errorMessage); - return { completed: true, value: { html: errorHtml, changeCount: 0 } }; + const productName = args.productName ?? 'SynthOS'; + const errorHtml = appendChatError(annotatedHtml, message, result.error.message, productName); + const clean = stripNodeIds(errorHtml); + return { completed: true, value: { html: clean, changeCount: -1 } }; } } } catch (err: unknown) { - // On any error: return original page with error block injected - const stripped = stripLineNumbers(numberedHtml); - const cleanOriginal = stripNodeIds(stripped); + // On any error: append error message to chat + const productName = args.productName ?? 'SynthOS'; const errorMessage = err instanceof Error ? err.message : String(err); - const errorHtml = injectError(cleanOriginal, 'Something went wrong try again', errorMessage); - return { completed: true, value: { html: errorHtml, changeCount: 0 } }; + const errorHtml = appendChatError(annotatedHtml, message, errorMessage, productName); + const clean = stripNodeIds(errorHtml); + return { completed: true, value: { html: clean, changeCount: -1 } }; } } @@ -266,63 +259,54 @@ function stripErrorCapture(html: string): string { } /** - * Add line numbers to inline ``; - - // Remove any existing error block first - $('script#error').remove(); - - // Inject before closing or at end - if ($('body').length > 0) { - $('body').append(scriptTag); - } else { - return html + scriptTag; + const chatMessages = $('#chatMessages'); + if (chatMessages.length > 0) { + chatMessages.append( + `

User: ${escapeHtml(userMessage)}

` + ); + chatMessages.append( + `

${escapeHtml(productName)}: Something went wrong \u2014 please try again.

` + + `

${escapeHtml(errorDetails)}

` + ); } - return $.html(); } @@ -665,7 +693,9 @@ Do not add duplicate script blocks with the same logic! Consolidate inline scrip Each element in the CURRENT_PAGE has a data-node-id attribute. Don't use the id attribute for targeting nodes (reserve it for scripts and styles) — use data-node-id. If you're trying to assign an id to script or style block, use "replace" not "update". -Your first operation should always be an update to your thoughts block, where you can reason through the user's request and plan your changes before applying them to the page.`; +Your first operation should always be an update to your thoughts block, where you can reason through the user's request and plan your changes before applying them to the page. + +CRITICAL — FluentLM components: If a section is present, you MUST use those components for all standard UI elements (buttons, inputs, selects, dialogs, tabs, cards, toggles, etc.). Never create custom CSS classes for UI controls that have a FluentLM equivalent. Refer to for the exact class names and markup patterns.`; } export const AGENT_API_REFERENCE = @@ -761,10 +791,16 @@ DELETE /api/pages/:name description: Delete a user page (cannot delete required/system pages) response: { deleted: true } +POST /api/pages/:name/ask +description: Ask a question about a page with full HTML context +request: { question: string } +response: { answer: string } + synthos.pages.list() — GET /api/pages synthos.pages.get(name) — GET /api/pages/:name synthos.pages.update(name, metadata) — POST /api/pages/:name - synthos.pages.remove(name) — DELETE /api/pages/:name`], + synthos.pages.remove(name) — DELETE /api/pages/:name + synthos.pages.ask(name, question) — POST /api/pages/:name/ask`], ['scripts', `POST /api/scripts/:id description: Execute a script with the passed in variables @@ -815,6 +851,74 @@ response: Upstream API response (JSON or text) synthos.connectors.call(connector, method, path, opts?) — POST /api/connectors (proxy call; opts: { headers?, body?, query? }) synthos.connectors.list(opts?) — GET /api/connectors (opts: { category?, id? })`], + + ['files', `GET /api/files/:page +description: List files stored for a page (with sizes) +response: { files: [{ name: string, size: number }] } + +POST /api/files/:page +description: Upload a file to a page's file storage (raw body + x-filename header) +request: Raw binary body with x-filename header +response: { name: string, size: number } + +GET /api/files/:page/:filename +description: Download/serve a specific file from a page's file storage +response: File content (served with appropriate content-type) + +DELETE /api/files/:page/:filename +description: Delete a file from a page's file storage +response: { deleted: true } + + synthos.files.list() — GET /api/files/:page (auto-scoped to current page) + synthos.files.upload(filename, blob) — POST /api/files/:page (auto-scoped to current page; sends raw body with x-filename header) + synthos.files.url(filename) — returns URL string /api/files/:page/:filename (for , , etc.) + synthos.files.remove(filename) — DELETE /api/files/:page/:filename (auto-scoped to current page)`], + + ['shared-data', `GET /api/shared/data/:table +description: Retrieve all rows from a shared (cross-page) table. Supports pagination via query params. +query params: limit (number, optional) — max rows to return; offset (number, optional, default 0) — rows to skip +response (without limit): Array of JSON rows [{ id: string, ... }] +response (with limit): { items: [{ id: string, ... }], total: number, offset: number, limit: number, hasMore: boolean } + +GET /api/shared/data/:table/:id +description: Retrieve a single row from a shared table +response: JSON row { id: string, ... } + +POST /api/shared/data/:table +description: Replaces or adds a single row to a shared table and returns the row +request: JSON row { id?: string, ... } +response: { id: string, ... } + +DELETE /api/shared/data/:table/:id +description: Delete a single row from a shared table +response: { success: true } + + synthos.shared.data.list(table, opts?) — GET /api/shared/data/:table (opts: { limit?, offset? } — when limit is set, returns { items, total, offset, limit, hasMore }) + synthos.shared.data.get(table, id) — GET /api/shared/data/:table/:id + synthos.shared.data.save(table, row) — POST /api/shared/data/:table + synthos.shared.data.remove(table, id) — DELETE /api/shared/data/:table/:id`], + + ['shared-files', `GET /api/shared/files +description: List files in shared (cross-page) file storage (with sizes) +response: { files: [{ name: string, size: number }] } + +POST /api/shared/files +description: Upload a file to shared file storage (raw body + x-filename header) +request: Raw binary body with x-filename header +response: { name: string, size: number } + +GET /api/shared/files/:filename +description: Download/serve a specific file from shared file storage +response: File content (served with appropriate content-type) + +DELETE /api/shared/files/:filename +description: Delete a file from shared file storage +response: { deleted: true } + + synthos.shared.files.list() — GET /api/shared/files + synthos.shared.files.upload(filename, blob) — POST /api/shared/files (sends raw body with x-filename header) + synthos.shared.files.url(filename) — returns URL string /api/shared/files/:filename (for , , etc.) + synthos.shared.files.remove(filename) — DELETE /api/shared/files/:filename`], ]); /** @@ -890,6 +994,11 @@ DELETE /api/pages/:name description: Delete a user page (cannot delete required/system pages) response: { deleted: true } +POST /api/pages/:name/ask +description: Ask a question about a page with full HTML context +request: { question: string } +response: { answer: string } + POST /api/scripts/:id description: Execute a script with the passed in variables request: { [key: string]: string } @@ -939,6 +1048,7 @@ PAGE HELPERS (available globally as window.synthos): synthos.pages.get(name) — GET /api/pages/:name synthos.pages.update(name, metadata) — POST /api/pages/:name synthos.pages.remove(name) — DELETE /api/pages/:name + synthos.pages.ask(name, question) — POST /api/pages/:name/ask synthos.search.web(query, opts?) — POST /api/search/web (opts: { count?, country?, freshness? }) synthos.connectors.call(connector, method, path, opts?) — POST /api/connectors (proxy call; opts: { headers?, body?, query? }) synthos.connectors.list(opts?) — GET /api/connectors (opts: { category?, id? }) diff --git a/src/service/useAgentRoutes.ts b/src/service/useAgentRoutes.ts index d8c3696..87ec033 100644 --- a/src/service/useAgentRoutes.ts +++ b/src/service/useAgentRoutes.ts @@ -12,11 +12,14 @@ import { disconnectAgent, getAgentStatus, getTunnelStatus, + setOpenClawDebug, } from '../agents'; import { v4 as uuidv4 } from 'uuid'; import { Customizer } from '../customizer'; export function useAgentRoutes(config: SynthOSConfig, app: Application, customizer?: Customizer): void { + // Enable OpenClaw debug logging only when --debug is passed + setOpenClawDebug(config.debug); /** Strip the token and sshTunnel.password fields, add connection/tunnel status for agent responses. */ function toClientAgent(agent: AgentConfig): Record { diff --git a/src/service/useApiRoutes.ts b/src/service/useApiRoutes.ts index 2bbdc04..825344c 100644 --- a/src/service/useApiRoutes.ts +++ b/src/service/useApiRoutes.ts @@ -1,8 +1,8 @@ import path from "path"; import fs from "fs/promises"; import AdmZip from "adm-zip"; -import { listPages, loadPageMetadata, PageMetadata, savePageMetadata, deletePage, copyPage, loadPageState, savePageState, PAGE_VERSION } from "../pages"; -import { checkIfExists, copyFile, copyFolderRecursive, deleteFile, ensureFolderExists, loadFile } from "../files"; +import { listPages, loadPageMetadata, PageMetadata, savePageMetadata, deletePage, copyPage, loadPageState, savePageState, clearVersions, PAGE_VERSION } from "../pages"; +import { checkIfExists, copyFile, copyFolderRecursive, deleteFile, ensureFolderExists, findFileInFolders, listFolders, loadFile } from "../files"; import {getModelEntry, loadSettings, saveSettings, ServicesConfig } from "../settings"; import { Application } from 'express'; import express from 'express'; @@ -12,7 +12,7 @@ import { generateDefaultImage, generateImage } from "./generateImage"; import { chainOfThought } from "../models"; import { requiresSettings } from "./requiresSettings"; import { executeScript } from "../scripts"; -import { listThemes, loadTheme, loadThemeInfo } from "../themes"; +import { listThemes, loadTheme, loadThemeInfo, loadThemeVersion } from "../themes"; import { migratePage } from "../migrations"; import { loadPageWithFallback } from "./usePageRoutes"; import { Customizer } from "../customizer"; @@ -52,7 +52,7 @@ const SERVICE_REGISTRY: ServiceDefinition[] = [ export function useApiRoutes(config: SynthOSConfig, app: Application, customizer?: Customizer): void { // List pages app.get('/api/pages', async (req, res) => { - const pages = await listPages(config.pagesFolder, config.requiredPagesFolder); + const pages = await listPages(config.pagesFolder, config.requiredPagesFolders); res.json(pages); }); @@ -150,7 +150,7 @@ export function useApiRoutes(config: SynthOSConfig, app: Application, customizer app.get('/api/pages/:name', async (req, res) => { try { const { name } = req.params; - const metadata = await loadPageMetadata(config.pagesFolder, name, config.requiredPagesFolder); + const metadata = await loadPageMetadata(config.pagesFolder, name, config.requiredPagesFolders); if (metadata) { res.json(metadata); } else { @@ -201,7 +201,7 @@ export function useApiRoutes(config: SynthOSConfig, app: Application, customizer } // Load existing metadata (or defaults) - const existing = await loadPageMetadata(config.pagesFolder, name, config.requiredPagesFolder); + const existing = await loadPageMetadata(config.pagesFolder, name, config.requiredPagesFolders); const metadata: PageMetadata = { title: existing?.title ?? '', categories: existing?.categories ?? [], @@ -227,7 +227,11 @@ export function useApiRoutes(config: SynthOSConfig, app: Application, customizer if (metadata.mode !== 'locked') { const userPagePath = path.join(config.pagesFolder, 'pages', name, 'page.html'); if (!(await checkIfExists(userPagePath))) { - const html = await loadPageState(config.requiredPagesFolder, name, false); + let html: string | undefined; + for (const folder of config.requiredPagesFolders) { + html = await loadPageState(folder, name); + if (html) break; + } if (html) { await savePageState(config.pagesFolder, name, html); } @@ -253,7 +257,7 @@ export function useApiRoutes(config: SynthOSConfig, app: Application, customizer } // Load existing metadata (user override → fallback .json → defaults) - let metadata = await loadPageMetadata(config.pagesFolder, name, config.requiredPagesFolder); + let metadata = await loadPageMetadata(config.pagesFolder, name, config.requiredPagesFolders); if (!metadata) { metadata = { title: '', @@ -304,11 +308,56 @@ export function useApiRoutes(config: SynthOSConfig, app: Application, customizer } }); + // Discover what a page contains (tables + files) + app.get('/api/pages/:name/contents', async (req, res) => { + try { + const { name } = req.params; + + // Resolve page folder: user pages first, then required pages + let pageFolder: string | undefined; + const userFolder = path.join(config.pagesFolder, 'pages', name); + if (await checkIfExists(path.join(userFolder, 'page.html'))) { + pageFolder = userFolder; + } else { + for (const folder of config.requiredPagesFolders) { + const candidate = path.join(folder, name); + if (await checkIfExists(path.join(candidate, 'page.html'))) { + pageFolder = candidate; + break; + } + } + } + + if (!pageFolder) { + res.status(404).json({ error: `Page "${name}" not found` }); + return; + } + + // List subdirectories, filtering out non-table entries + const EXCLUDED = new Set(['files']); + const subdirs = await listFolders(pageFolder); + const tables = subdirs.filter(d => !EXCLUDED.has(d)); + + // Check if files/ exists and has entries + const filesDir = path.join(pageFolder, 'files'); + let hasFiles = false; + if (await checkIfExists(filesDir)) { + const entries = await fs.readdir(filesDir); + hasFiles = entries.length > 0; + } + + res.json({ tables, hasFiles }); + } catch (err: unknown) { + console.error(err); + res.status(500).json({ error: (err as Error).message }); + } + }); + // Copy a page to a new name app.post('/api/pages/:name/copy', async (req, res) => { try { const sourceName = req.params.name; - const { name: targetName, title, categories } = req.body; + const { name: targetName, title, categories, copyTables, copyFiles } = req.body; // Validate target name if (!targetName || typeof targetName !== 'string') { @@ -330,10 +379,14 @@ export function useApiRoutes(config: SynthOSConfig, app: Application, customizer // Check source exists (user pages → required pages) const sourceFolderPath = path.join(config.pagesFolder, 'pages', sourceName, 'page.html'); const sourceFlatPath = path.join(config.pagesFolder, `${sourceName}.html`); - const sourceRequiredPath = path.join(config.requiredPagesFolder, `${sourceName}.html`); + let sourceRequiredPath: string | undefined; + for (const folder of config.requiredPagesFolders) { + const candidate = path.join(folder, sourceName, 'page.html'); + if (await checkIfExists(candidate)) { sourceRequiredPath = candidate; break; } + } const sourceExists = await checkIfExists(sourceFolderPath) || await checkIfExists(sourceFlatPath) - || await checkIfExists(sourceRequiredPath); + || !!sourceRequiredPath; if (!sourceExists) { res.status(404).json({ error: `Source page "${sourceName}" not found` }); return; @@ -353,7 +406,11 @@ export function useApiRoutes(config: SynthOSConfig, app: Application, customizer targetName, typeof title === 'string' ? title : '', Array.isArray(categories) ? categories : [], - config.requiredPagesFolder + config.requiredPagesFolders, + { + copyTables: copyTables === true, + copyFiles: copyFiles !== false, // default true + } ); // Return the new page metadata @@ -527,7 +584,13 @@ Return ONLY the JSON object.`}; res.status(404).send(`// Theme info for "${themeName}" not found`); return; } - const js = `window.themeInfo=${JSON.stringify(info)};document.documentElement.classList.add(window.themeInfo.mode+"-mode");`; + const themeVersion = await loadThemeVersion(themeName, config); + const payload = { ...info, name: themeName, version: themeVersion }; + let js = `window.themeInfo=${JSON.stringify(payload)};document.documentElement.classList.add(window.themeInfo.mode+"-mode");`; + if (themeVersion >= 3) { + js += `document.documentElement.classList.add(${JSON.stringify(themeName)});`; + } + js += `document.documentElement.setAttribute("data-toolbar",${JSON.stringify(settings.toolbarPosition || 'left')});`; res.set('Content-Type', 'application/javascript'); res.send(js); } catch (err: unknown) { @@ -544,21 +607,17 @@ Return ONLY the JSON object.`}; res.status(400).send('// Missing page query parameter'); return; } - const metadata = await loadPageMetadata(config.pagesFolder, page, config.requiredPagesFolder); + const metadata = await loadPageMetadata(config.pagesFolder, page, config.requiredPagesFolders); const mode = metadata?.mode ?? 'unlocked'; const title = metadata?.title ?? ''; const categories = metadata?.categories ?? []; - const info = JSON.stringify({ name: page, mode, latestPageVersion: PAGE_VERSION, title, categories }); + const isRequiredPage = config.requiredPages.includes(page); + const info = JSON.stringify({ name: page, mode, latestPageVersion: PAGE_VERSION, title, categories, isRequiredPage }); const js = [ `window.pageInfo=${info};`, `if(window.pageInfo.mode==="locked"){`, `document.addEventListener("DOMContentLoaded",function(){`, `var f=document.getElementById("chatForm");if(f)f.style.display="none";`, - `var s=document.getElementById("saveLink");if(s)s.textContent="Copy";`, - `var r=document.getElementById("resetLink");if(r){`, - `var c=r.cloneNode(true);c.textContent="Reload";`, - `c.addEventListener("click",function(e){e.preventDefault();window.location.href=window.location.pathname;});`, - `r.parentNode.replaceChild(c,r);}`, `});`, `}`, ].join(''); @@ -608,8 +667,8 @@ Return ONLY the JSON object.`}; res.status(400).send('// Invalid version parameter'); return; } - const scriptPath = path.join(config.pageScriptsFolder, `page-v${v}.js`); - if (!(await checkIfExists(scriptPath))) { + const scriptPath = await findFileInFolders(config.staticFilesFolders, `page.v${v}.js`); + if (!scriptPath) { res.status(404).send(`// page-v${v}.js not found`); return; } @@ -631,8 +690,8 @@ Return ONLY the JSON object.`}; res.status(400).send('// Invalid version parameter'); return; } - const scriptPath = path.join(config.pageScriptsFolder, `helpers-v${v}.js`); - if (!(await checkIfExists(scriptPath))) { + const scriptPath = await findFileInFolders(config.staticFilesFolders, `helpers.v${v}.js`); + if (!scriptPath) { res.status(404).send(`// helpers-v${v}.js not found`); return; } @@ -766,7 +825,7 @@ Return ONLY the JSON object.`}; const { name } = req.params; // Load current metadata - const metadata = await loadPageMetadata(config.pagesFolder, name, config.requiredPagesFolder); + const metadata = await loadPageMetadata(config.pagesFolder, name, config.requiredPagesFolders); if (!metadata) { res.status(404).json({ error: `Page "${name}" not found` }); return; @@ -808,6 +867,9 @@ Return ONLY the JSON object.`}; await copyFolderRecursive(folderPath, path.join(migratedFolder, name)); } + // Clear stale version files (undo snapshots from the old page version) + await clearVersions(config.pagesFolder, name); + // Update metadata metadata.pageVersion = PAGE_VERSION; metadata.lastModified = new Date().toISOString(); @@ -827,21 +889,27 @@ Return ONLY the JSON object.`}; // Try user pages folder first, then required pages const userPageDir = path.join(config.pagesFolder, 'pages', name); - const requiredPageFile = path.join(config.requiredPagesFolder, `${name}.html`); + let requiredPageDir: string | undefined; + for (const folder of config.requiredPagesFolders) { + if (await checkIfExists(path.join(folder, name, 'page.html'))) { + requiredPageDir = path.join(folder, name); + break; + } + } let sourceDir: string | null = null; if (await checkIfExists(path.join(userPageDir, 'page.html'))) { sourceDir = userPageDir; - } else if (await checkIfExists(requiredPageFile)) { + } else if (requiredPageDir) { // For required pages, create a temp-like zip with just the HTML const zip = new AdmZip(); - const html = await loadFile(requiredPageFile); + const html = await loadFile(path.join(requiredPageDir, 'page.html')); zip.addFile(`${name}/page.html`, Buffer.from(html, 'utf-8')); // Include page.json if it exists - const requiredMetaFile = path.join(config.requiredPagesFolder, `${name}.json`); - if (await checkIfExists(requiredMetaFile)) { - const meta = await loadFile(requiredMetaFile); + const metaPath = path.join(requiredPageDir, 'page.json'); + if (await checkIfExists(metaPath)) { + const meta = await loadFile(metaPath); zip.addFile(`${name}/page.json`, Buffer.from(meta, 'utf-8')); } @@ -868,4 +936,39 @@ Return ONLY the JSON object.`}; res.status(500).json({ error: (err as Error).message }); } }); + + // Ask a question about a page (with full page HTML context) + app.post('/api/pages/:name/ask', async (req, res) => { + await requiresSettings(res, config.pagesFolder, async (settings) => { + const { name } = req.params; + const { question } = req.body; + if (typeof question !== 'string' || !question.trim()) { + res.status(400).json({ error: 'question is required' }); + return; + } + + // Load the page HTML + const html = await loadPageWithFallback(name, config, false); + if (!html) { + res.status(404).json({ error: `Page "${name}" not found` }); + return; + } + + // Create completion (uses 'chat' model, not 'builder') + const complete = await createCompletePrompt(config.pagesFolder, 'chat', req.body.model); + + const system = { + role: 'system' as const, + content: `You are a helpful assistant. The user will ask questions about a web page. Answer based on the page content provided.\n\n\n${html}` + }; + const prompt = { role: 'user' as const, content: question }; + + const result = await complete({ system, prompt }); + if (result.completed) { + res.json({ answer: result.value }); + } else { + res.status(500).json({ error: result.error?.message || 'Completion failed' }); + } + }); + }); } \ No newline at end of file diff --git a/src/service/useConnectorRoutes.ts b/src/service/useConnectorRoutes.ts index 78995f8..3e72d23 100644 --- a/src/service/useConnectorRoutes.ts +++ b/src/service/useConnectorRoutes.ts @@ -21,7 +21,7 @@ export function useConnectorRoutes(config: SynthOSConfig, app: Application): voi const categoryFilter = req.query.category as string | undefined; const idFilter = req.query.id as string | undefined; - const list: ConnectorSummary[] = getConnectorRegistry(config.serviceConnectorsFolder) + const list: ConnectorSummary[] = getConnectorRegistry(config.serviceConnectorsFolders) .filter(def => { if (categoryFilter && def.category !== categoryFilter) return false; if (idFilter && def.id !== idFilter) return false; @@ -54,7 +54,7 @@ export function useConnectorRoutes(config: SynthOSConfig, app: Application): voi app.get('/api/connectors/:id', async (req, res) => { try { const { id } = req.params; - const def = getConnectorRegistry(config.serviceConnectorsFolder).find(d => d.id === id); + const def = getConnectorRegistry(config.serviceConnectorsFolders).find(d => d.id === id); if (!def) { res.status(404).json({ error: `Connector "${id}" not found` }); return; @@ -88,7 +88,7 @@ export function useConnectorRoutes(config: SynthOSConfig, app: Application): voi app.post('/api/connectors/:id', async (req, res) => { try { const { id } = req.params; - const def = getConnectorRegistry(config.serviceConnectorsFolder).find(d => d.id === id); + const def = getConnectorRegistry(config.serviceConnectorsFolders).find(d => d.id === id); if (!def) { res.status(404).json({ error: `Connector "${id}" not found` }); return; @@ -164,7 +164,7 @@ export function useConnectorRoutes(config: SynthOSConfig, app: Application): voi app.get('/api/connectors/:id/authorize', async (req, res) => { try { const { id } = req.params; - const def = getConnectorRegistry(config.serviceConnectorsFolder).find(d => d.id === id); + const def = getConnectorRegistry(config.serviceConnectorsFolders).find(d => d.id === id); if (!def || def.authStrategy !== 'oauth2') { res.status(400).json({ error: `Connector "${id}" is not an OAuth2 connector` }); return; @@ -214,7 +214,7 @@ export function useConnectorRoutes(config: SynthOSConfig, app: Application): voi const state = JSON.parse(stateRaw) as { connector: string }; const connectorId = state.connector; - const def = getConnectorRegistry(config.serviceConnectorsFolder).find(d => d.id === connectorId); + const def = getConnectorRegistry(config.serviceConnectorsFolders).find(d => d.id === connectorId); if (!def || def.authStrategy !== 'oauth2') { res.status(400).json({ error: `Unknown OAuth2 connector: ${connectorId}` }); return; @@ -326,7 +326,7 @@ export function useConnectorRoutes(config: SynthOSConfig, app: Application): voi return; } - const def = getConnectorRegistry(config.serviceConnectorsFolder).find(d => d.id === request.connector); + const def = getConnectorRegistry(config.serviceConnectorsFolders).find(d => d.id === request.connector); if (!def) { res.status(404).json({ error: `Connector "${request.connector}" not found` }); return; diff --git a/src/service/useFileRoutes.ts b/src/service/useFileRoutes.ts new file mode 100644 index 0000000..d19c7f7 --- /dev/null +++ b/src/service/useFileRoutes.ts @@ -0,0 +1,127 @@ +import { Application } from 'express'; +import express from 'express'; +import fs from 'fs/promises'; +import path from 'path'; +import { SynthOSConfig } from '../init'; +import { checkIfExists, ensureFolderExists } from '../files'; + +export function useFileRoutes(config: SynthOSConfig, app: Application): void { + // List files in a page's files/ folder + app.get('/api/files/:page', async (req, res) => { + try { + const folder = filesFolder(config, req.params.page); + if (!(await checkIfExists(folder))) { + res.json({ files: [] }); + return; + } + + const entries = await fs.readdir(folder); + const files: { name: string; size: number }[] = []; + for (const entry of entries) { + const stat = await fs.stat(path.join(folder, entry)); + if (stat.isFile()) { + files.push({ name: entry, size: stat.size }); + } + } + res.json({ files }); + } catch (err: unknown) { + console.error(err); + res.status(500).json({ error: (err as Error).message }); + } + }); + + // Download/serve a specific file + app.get('/api/files/:page/:filename', async (req, res) => { + try { + const filePath = safeFilePath(config, req.params.page, req.params.filename); + if (!filePath) { + res.status(400).json({ error: 'Invalid filename' }); + return; + } + + if (!(await checkIfExists(filePath))) { + res.status(404).json({ error: 'File not found' }); + return; + } + + res.sendFile(filePath); + } catch (err: unknown) { + console.error(err); + res.status(500).json({ error: (err as Error).message }); + } + }); + + // Upload a file (raw body + x-filename header) + app.post('/api/files/:page', express.raw({ type: '*/*', limit: '50mb' }), async (req, res) => { + try { + const filename = req.headers['x-filename'] as string | undefined; + if (!filename || filename.trim().length === 0) { + res.status(400).json({ error: 'x-filename header is required' }); + return; + } + + const filePath = safeFilePath(config, req.params.page, filename); + if (!filePath) { + res.status(400).json({ error: 'Invalid filename' }); + return; + } + + const folder = filesFolder(config, req.params.page); + await ensureFolderExists(folder); + await fs.writeFile(filePath, req.body as Buffer); + + const stat = await fs.stat(filePath); + res.status(201).json({ name: filename, size: stat.size }); + } catch (err: unknown) { + console.error(err); + res.status(500).json({ error: (err as Error).message }); + } + }); + + // Delete a file + app.delete('/api/files/:page/:filename', async (req, res) => { + try { + const filePath = safeFilePath(config, req.params.page, req.params.filename); + if (!filePath) { + res.status(400).json({ error: 'Invalid filename' }); + return; + } + + if (!(await checkIfExists(filePath))) { + res.status(404).json({ error: 'File not found' }); + return; + } + + await fs.unlink(filePath); + res.json({ deleted: true }); + } catch (err: unknown) { + console.error(err); + res.status(500).json({ error: (err as Error).message }); + } + }); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function filesFolder(config: SynthOSConfig, page: string): string { + return path.join(config.pagesFolder, 'pages', page, 'files'); +} + +/** + * Resolve a filename inside the page's files/ folder with path-traversal protection. + * Returns the absolute path if safe, or null if the filename is invalid. + */ +function safeFilePath(config: SynthOSConfig, page: string, filename: string): string | null { + // Reject obviously bad filenames + if (!filename || filename.includes('..') || filename.includes('/') || filename.includes('\\')) { + return null; + } + const folder = filesFolder(config, page); + const resolved = path.resolve(folder, filename); + if (!resolved.startsWith(path.resolve(folder))) { + return null; + } + return resolved; +} diff --git a/src/service/usePageRoutes.ts b/src/service/usePageRoutes.ts index 7a3a768..a55e3e3 100644 --- a/src/service/usePageRoutes.ts +++ b/src/service/usePageRoutes.ts @@ -1,4 +1,4 @@ -import { loadPageMetadata, loadPageState, normalizePageName, PAGE_VERSION, savePageMetadata, savePageState, updatePageState } from "../pages"; +import { loadPageMetadata, loadPageState, normalizePageName, PAGE_VERSION, savePageMetadata, savePageState, savePageVersion, loadPageVersion, getLatestVersion, clearVersions } from "../pages"; import { getModelEntry, hasConfiguredSettings, loadSettings } from "../settings"; import { Application } from 'express'; import { transformPage, buildRouteHints, serverAPIs, AGENT_API_REFERENCE } from "./transformPage"; @@ -6,12 +6,14 @@ import { SynthOSConfig } from "../init"; import { createCompletePrompt } from "./createCompletePrompt"; import { completePrompt } from "../models"; import { green, red, dim, estimateTokens } from "./debugLog"; -import { loadThemeInfo, ThemeInfo } from "../themes"; +import { loadThemeInfo, loadThemeVersion, ThemeInfo } from "../themes"; import { Customizer } from "../customizer"; import { createBuilder, ContextSection, Attachment } from "../builders"; import { getConnectorRegistry, ConnectorsConfig, ConnectorOAuthConfig } from "../connectors"; import { AgentConfig } from "../agents"; import { listScripts } from "../scripts"; +import path from 'path'; +import { checkIfExists, copyFolderRecursive, deleteFile as deleteFileFromDisk, findFileInFolders, loadFile } from "../files"; import * as cheerio from 'cheerio'; /** @@ -20,6 +22,7 @@ import * as cheerio from 'cheerio'; */ const REQUIRED_IMPORTS: { selector: string; src: string }[] = [ { selector: 'script[src*="marked"]', src: 'https://cdnjs.cloudflare.com/ajax/libs/marked/14.1.1/marked.min.js' }, + { selector: 'script[src*="html2canvas"]', src: 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js' }, ]; /** @@ -95,6 +98,51 @@ function injectPageScript(html: string, pageVersion: number): string { return html + '\n' + tag; } +/** + * Wrap each inline `; + }); +} + +/** + * Move any external '; + $('body').append(fluentScript + '\n'); + } + } + + $('html').attr('data-toolbar', toolbarPosition || 'left'); + + return $.html(); +} + // --------------------------------------------------------------------------- // Context section builders — assemble ContextSections from enabled features // --------------------------------------------------------------------------- +function buildContextSection(): ContextSection { + const now = new Date(); + const dateTime = now.toLocaleString('en-US', { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', + hour: 'numeric', minute: '2-digit', hour12: true, + }); + return { + title: '', + content: `Current date and time: ${dateTime}`, + instructions: '', + }; +} + function buildServerApisSection(customizer?: Customizer): ContextSection { const content = customizer ? buildRouteHints(customizer) : serverAPIs; return { @@ -233,7 +347,7 @@ function buildThemeSection(themeInfo?: ThemeInfo): ContextSection { const colorList = Object.entries(colors) .map(([name, value]) => ` --${name}: ${value}`) .join('\n'); - content = `Mode: ${mode}\nCSS custom properties (use instead of hardcoded values):\n${colorList}\n\nShared shell classes (pre-styled by theme, do not redefine):\n .chat-panel — Left sidebar container (30% width)\n .chat-header — Chat panel title bar\n .chat-messages — Scrollable message container\n .chat-message — Individual message wrapper\n .link-group — Navigation links row (Save, Pages, Reset)\n .chat-input — Message text input\n .chat-submit — Send button\n .viewer-panel — Right content area (70% width)\n .loading-overlay — Full-screen loading overlay\n .spinner — Animated loading spinner\n .modal-overlay — Full-screen modal backdrop (position:fixed, z-index:2000, backdrop-filter:blur). Add class "show" to display.\n .modal-content — Centered modal container\n .modal-header — Gradient header bar\n .modal-body — Modal content area\n .modal-footer — Bottom action bar (flex, space-between)\n .modal-footer-right — Right-aligned button group\n\nModals and popups: ALWAYS use the theme\'s .modal-overlay class for any modal or popup overlay. Do NOT create custom overlay classes with position:fixed and z-index. Structure:\n \nShow/hide by toggling the "show" class: el.classList.add(\'show\') / el.classList.remove(\'show\'). This ensures correct z-index layering above the chat toggle and other UI elements.\n\nPage title bars: To align with the chat header, apply these styles:\n min-height: var(--header-min-height);\n padding: var(--header-padding-vertical) var(--header-padding-horizontal);\n line-height: var(--header-line-height);\n display: flex; align-items: center; justify-content: center; box-sizing: border-box;\n\nFull-viewer mode: For games, animations, or full-screen content, add class "full-viewer" to the viewer-panel element to remove its padding.\n\nChat panel behaviours (auto-injected via page script — do NOT recreate in page code):\n The server injects page-v2.js after transformation. It provides:\n - Form submit handler: sets action to window.location.pathname, shows #loadingOverlay, disables inputs\n - Save/Reset link handlers (#saveLink, #resetLink)\n - Chat scroll to bottom (#chatMessages)\n - Chat toggle button (.chat-toggle) — created dynamically if not in markup\n - .chat-input-wrapper — wraps #chatInput with a brainstorm icon button\n - Brainstorm modal (#brainstormModal) — LLM-powered brainstorm UI, created dynamically\n - Focus management — keeps keyboard input directed to #chatInput\n\n Do NOT:\n - Create your own form submit handler, toggle button, or input wrapper\n - Modify or replace .chat-panel, .chat-header, .link-group, #chatForm, or .chat-toggle\n - INSERT new '; - const result = addLineNumbers(html); - assert.ok(result.includes('01: let x = 1;')); - assert.ok(result.includes('02: let y = 2;')); - assert.ok(result.includes('03: return x + y;')); +describe('normalizedIndexOf', () => { + it('returns null when needle is not found', () => { + assert.strictEqual(normalizedIndexOf('hello world', 'xyz'), null); }); - it('adds 2-digit line numbers to style content', () => { - const html = ''; - const result = addLineNumbers(html); - assert.ok(result.includes('01: .a { color: red; }')); - assert.ok(result.includes('02: .b { color: blue; }')); + it('finds exact match and returns correct positions', () => { + const result = normalizedIndexOf('let x = 1;', 'x = 1'); + assert.ok(result !== null); + assert.strictEqual(result.start, 4); + assert.strictEqual(result.end, 9); }); - it('uses 3-digit padding when 100+ lines', () => { - const lines = Array.from({ length: 100 }, (_, i) => `let v${i} = ${i};`).join('\n'); - const html = ``; - const result = addLineNumbers(html); - assert.ok(result.includes('001: let v0 = 0;')); - assert.ok(result.includes('100: let v99 = 99;')); + it('matches despite whitespace differences', () => { + const haystack = 'let x =\n 1;'; + const needle = 'x = 1;'; + const result = normalizedIndexOf(haystack, needle); + assert.ok(result !== null); + // Should span from 'x' to end of ';' + const matched = haystack.slice(result.start, result.end); + assert.ok(matched.includes('x')); + assert.ok(matched.includes('1;')); }); - it('skips scripts with src attribute', () => { - const html = ''; - const result = addLineNumbers(html); - assert.ok(!result.includes('01:')); - }); - - it('skips scripts with type="application/json"', () => { - const html = ''; - const result = addLineNumbers(html); - assert.ok(!result.includes('01:')); - }); - - it('leaves empty script blocks unchanged', () => { - const html = ''; - const result = addLineNumbers(html); - assert.ok(result.includes('')); + it('handles newlines vs spaces', () => { + const haystack = 'function foo() {\n return 1;\n}'; + const needle = 'foo() { return 1; }'; + const result = normalizedIndexOf(haystack, needle); + assert.ok(result !== null); }); }); // --------------------------------------------------------------------------- -// stripLineNumbers +// applyChangeList — search-replace / search-insert ops // --------------------------------------------------------------------------- -describe('stripLineNumbers', () => { - it('strips 2-digit line number prefixes', () => { - const html = ''; - const result = stripLineNumbers(html); - assert.ok(result.includes('let x = 1;\nlet y = 2;')); - assert.ok(!result.includes('01:')); - }); - - it('strips 3-digit line number prefixes', () => { - const html = ''; - const result = stripLineNumbers(html); - assert.ok(result.includes('let x = 1;\nlet y = 2;')); - assert.ok(!result.includes('001:')); - }); - - it('passes through lines without prefix unchanged', () => { - const html = ''; - const result = stripLineNumbers(html); - assert.ok(result.includes('no prefix here\nhas prefix')); - }); - - it('handles mixed lines (some with, some without prefixes)', () => { - const html = ''; - const result = stripLineNumbers(html); - assert.ok(result.includes('line one\nplain line\nline three')); - }); - - it('skips scripts with src attribute', () => { - const html = ''; - const result = stripLineNumbers(html); - assert.ok(result.includes('01: should stay')); - }); - - it('skips scripts with type="application/json"', () => { - const html = ''; - const result = stripLineNumbers(html); - assert.ok(result.includes('01: should stay')); - }); -}); - -// --------------------------------------------------------------------------- -// addLineNumbers -> stripLineNumbers roundtrip -// --------------------------------------------------------------------------- - -describe('addLineNumbers -> stripLineNumbers roundtrip', () => { - it('roundtrip preserves script content', () => { - const original = ''; - const numbered = addLineNumbers(original); - assert.ok(numbered.includes('01:')); - const stripped = stripLineNumbers(numbered); - assert.ok(!stripped.includes('01:')); - assert.ok(stripped.includes('let x = 1;\nlet y = 2;\nreturn x + y;')); - }); - - it('roundtrip preserves style content', () => { - const original = ''; - const numbered = addLineNumbers(original); - const stripped = stripLineNumbers(numbered); - assert.ok(stripped.includes('.a { color: red; }\n.b { color: blue; }')); - }); -}); - -// --------------------------------------------------------------------------- -// applyChangeList — line-range ops -// --------------------------------------------------------------------------- - -describe('applyChangeList — line-range ops', () => { +describe('applyChangeList — search-replace / search-insert ops', () => { const scriptHtml = '' + - '' + + '' + ''; - it('update-lines replaces a range of lines', () => { + it('search-replace replaces exact text match', () => { const changes: ChangeList = [ - { op: 'update-lines', nodeId: '5', startLine: 2, endLine: 3, content: 'let b = 20;\nlet c = 30;' }, + { op: 'search-replace', nodeId: '5', search: 'let b = 2;', replace: 'let b = 20;' }, ]; const result = applyChangeList(scriptHtml, changes); assert.ok(result.includes('let b = 20;')); - assert.ok(result.includes('let c = 30;')); - assert.ok(result.includes('01: let a = 1;')); - assert.ok(result.includes('04: let d = 4;')); - }); - - it('update-lines strips line number prefixes from model-provided content', () => { - const changes: ChangeList = [ - { op: 'update-lines', nodeId: '5', startLine: 2, endLine: 2, content: '02: let b = 99;' }, - ]; - const result = applyChangeList(scriptHtml, changes); - assert.ok(result.includes('let b = 99;')); - // Should not have double line number prefix - assert.ok(!result.includes('02: 02:')); + assert.ok(!result.includes('let b = 2;')); + assert.ok(result.includes('let a = 1;')); + assert.ok(result.includes('let c = 3;')); }); - it('update-lines can expand (replace 1 line with 3)', () => { + it('search-replace with empty replace deletes text', () => { const changes: ChangeList = [ - { op: 'update-lines', nodeId: '5', startLine: 3, endLine: 3, content: 'let c1 = 31;\nlet c2 = 32;\nlet c3 = 33;' }, + { op: 'search-replace', nodeId: '5', search: 'let c = 3;\n', replace: '' }, ]; const result = applyChangeList(scriptHtml, changes); - assert.ok(result.includes('let c1 = 31;')); - assert.ok(result.includes('let c2 = 32;')); - assert.ok(result.includes('let c3 = 33;')); - // Lines after should still be present - assert.ok(result.includes('04: let d = 4;')); + assert.ok(!result.includes('let c = 3;')); + assert.ok(result.includes('let b = 2;')); + assert.ok(result.includes('let d = 4;')); }); - it('update-lines can contract (replace 3 lines with 1)', () => { + it('search-replace falls back to normalized match', () => { + // Script has single spaces, search has different whitespace + const html = '' + + '' + + ''; const changes: ChangeList = [ - { op: 'update-lines', nodeId: '5', startLine: 2, endLine: 4, content: 'let combined = 234;' }, + { op: 'search-replace', nodeId: '5', search: 'function foo() { return 1; }', replace: 'function foo() { return 2; }' }, ]; - const result = applyChangeList(scriptHtml, changes); - assert.ok(result.includes('let combined = 234;')); - assert.ok(result.includes('01: let a = 1;')); - assert.ok(result.includes('05: let e = 5;')); - assert.ok(!result.includes('let b = 2;')); + const result = applyChangeList(html, changes); + assert.ok(result.includes('return 2;')); }); - it('delete-lines removes a range of lines', () => { + it('search-replace warns on no match found', () => { const changes: ChangeList = [ - { op: 'delete-lines', nodeId: '5', startLine: 2, endLine: 3 }, + { op: 'search-replace', nodeId: '5', search: 'nonexistent text', replace: 'replacement' }, ]; + // Should not throw const result = applyChangeList(scriptHtml, changes); - assert.ok(!result.includes('let b = 2;')); - assert.ok(!result.includes('let c = 3;')); - assert.ok(result.includes('01: let a = 1;')); - assert.ok(result.includes('04: let d = 4;')); + // Original content preserved + assert.ok(result.includes('let a = 1;')); + assert.ok(!result.includes('replacement')); }); - it('delete-lines removes a single line', () => { + it('search-replace works on style blocks', () => { + const styleHtml = ''; const changes: ChangeList = [ - { op: 'delete-lines', nodeId: '5', startLine: 3, endLine: 3 }, + { op: 'search-replace', nodeId: '3', search: '.b { color: blue; }', replace: '.b { color: purple; }' }, ]; - const result = applyChangeList(scriptHtml, changes); - assert.ok(!result.includes('let c = 3;')); - assert.ok(result.includes('02: let b = 2;')); - assert.ok(result.includes('04: let d = 4;')); + const result = applyChangeList(styleHtml, changes); + assert.ok(result.includes('.b { color: purple; }')); + assert.ok(!result.includes('color: blue')); }); - it('insert-lines inserts after a specific line', () => { + it('search-insert inserts after matched text', () => { const changes: ChangeList = [ - { op: 'insert-lines', nodeId: '5', afterLine: 2, content: 'let inserted = true;' }, + { op: 'search-insert', nodeId: '5', after: 'let b = 2;', content: '\nlet inserted = true;' }, ]; const result = applyChangeList(scriptHtml, changes); assert.ok(result.includes('let inserted = true;')); - // Verify ordering: line 2 content, then inserted, then line 3 content - const idx2 = result.indexOf('02: let b = 2;'); + const idxB = result.indexOf('let b = 2;'); const idxInserted = result.indexOf('let inserted = true;'); - const idx3 = result.indexOf('03: let c = 3;'); - assert.ok(idx2 < idxInserted); - assert.ok(idxInserted < idx3); - }); - - it('insert-lines at afterLine 0 inserts before first line', () => { - const changes: ChangeList = [ - { op: 'insert-lines', nodeId: '5', afterLine: 0, content: '// header comment' }, - ]; - const result = applyChangeList(scriptHtml, changes); - const idxComment = result.indexOf('// header comment'); - const idxFirst = result.indexOf('01: let a = 1;'); - assert.ok(idxComment < idxFirst); + const idxC = result.indexOf('let c = 3;'); + assert.ok(idxB < idxInserted); + assert.ok(idxInserted < idxC); }); - it('insert-lines at end inserts after last line', () => { + it('search-insert warns on no match found', () => { const changes: ChangeList = [ - { op: 'insert-lines', nodeId: '5', afterLine: 5, content: '// footer comment' }, + { op: 'search-insert', nodeId: '5', after: 'nonexistent text', content: '\nnew stuff' }, ]; const result = applyChangeList(scriptHtml, changes); - const idxLast = result.indexOf('05: let e = 5;'); - const idxComment = result.indexOf('// footer comment'); - assert.ok(idxLast < idxComment); + assert.ok(result.includes('let a = 1;')); + assert.ok(!result.includes('new stuff')); }); - it('warns but does not throw on missing node for line-range ops', () => { + it('warns but does not throw on missing node for search ops', () => { const ops: ChangeList = [ - { op: 'update-lines', nodeId: '999', startLine: 1, endLine: 1, content: 'x' }, - { op: 'delete-lines', nodeId: '999', startLine: 1, endLine: 1 }, - { op: 'insert-lines', nodeId: '999', afterLine: 1, content: 'x' }, + { op: 'search-replace', nodeId: '999', search: 'x', replace: 'y' }, + { op: 'search-insert', nodeId: '999', after: 'x', content: 'y' }, ]; for (const change of ops) { const result = applyChangeList(scriptHtml, [change]); - // Should not throw, content preserved assert.ok(result.includes('let a = 1;')); } }); - - it('works on '; - const changes: ChangeList = [ - { op: 'update-lines', nodeId: '3', startLine: 2, endLine: 2, content: '.b { color: purple; }' }, - ]; - const result = applyChangeList(styleHtml, changes); - assert.ok(result.includes('.b { color: purple; }')); - assert.ok(!result.includes('color: blue')); - }); }); // --------------------------------------------------------------------------- @@ -950,7 +836,7 @@ describe('transformPage', () => { assert.strictEqual(capturedNewBuild, false); }); - it('applies update-lines through full pipeline and strips line numbers', async () => { + it('applies search-replace through full pipeline', async () => { const pageWithScript = `

SynthOS: Welcome!

@@ -960,14 +846,14 @@ describe('transformPage', () => { `; const builder = makeBuilder(async (currentPage) => { - // The current page should have line numbers - assert.ok(currentPage.content.includes('01:'), 'currentPage should contain line numbers'); + // The current page should NOT have line numbers + assert.ok(!currentPage.content.includes('01:'), 'currentPage should not contain line numbers'); // Find the script node id const scriptNodeId = findNodeId(currentPage.content, 'id="page-script"'); return { kind: 'transforms', changes: [ - { op: 'update-lines', nodeId: scriptNodeId, startLine: 1, endLine: 1, content: 'let count = 42;' }, + { op: 'search-replace', nodeId: scriptNodeId, search: 'let count = 0;', replace: 'let count = 42;' }, ], }; }); @@ -977,8 +863,6 @@ describe('transformPage', () => { assert.ok(result.value); // Edit should be applied assert.ok(result.value.html.includes('let count = 42;')); - // Line numbers should be stripped - assert.ok(!result.value.html.match(/\d{2}: /), 'No line number prefixes should remain'); // Node ids should be stripped assert.ok(!result.value.html.includes('data-node-id')); assert.strictEqual(result.value.changeCount, 1);