diff --git a/NOTICE b/NOTICE
index 0879f582..293d90ca 100644
--- a/NOTICE
+++ b/NOTICE
@@ -3,11 +3,29 @@ Copyright 2026 Firefly Software Foundation.
Author: Firefly Software Foundation
SPDX-License-Identifier: Apache-2.0
-This product includes software and original visual assets developed for
-Firefly Weave by the Firefly Software Foundation.
+This product includes software developed for Firefly Weave by the Firefly
+Software Foundation, licensed under the Apache License 2.0.
-The original Weave SVG artwork uses the Firefly family's green palette and an
-independent woven W symbol. It does not incorporate the PyFly logo.
+Firefly marks. The Firefly name, the firefly wordmark with its spark and
+chevron, the Firefly icon and the Firefly Weave logo that combines them with
+the word "Weave" are trademarks and proprietary artwork of Firefly Software
+Solutions Inc. Copyright 2026 Firefly Software Solutions Inc. All rights
+reserved. The Firefly Software Foundation uses them in Firefly Weave with the
+permission of Firefly Software Solutions Inc. They are not licensed under the
+Apache License 2.0, and this notice grants no right to use, modify or
+redistribute them apart from unmodified copies of this product. The source
+inventory, docs/contributing/source-inventory.toml, lists every file that
+carries them.
+
+Manrope. Studio includes the Manrope typeface, Copyright 2019 The Manrope
+Project Authors (https://github.com/sharanda/manrope), licensed under the SIL
+Open Font License, Version 1.1 (assets/fonts/manrope/OFL.txt).
+
+Lucide. Studio's icons are derived from Lucide (https://lucide.dev),
+lucide-static 1.52.0, Copyright (c) 2026 Lucide Icons and Contributors,
+licensed under the ISC License; portions derive from Feather, Copyright (c)
+2013-present Cole Bemis, licensed under the MIT License
+(studio/public/licenses/lucide-LICENSE.txt).
Firefly Weave depends on separately distributed software, including PyFly.
Those dependencies retain their own copyrights, licenses, and notices. This
diff --git a/README.md b/README.md
index bbd55fc1..7a05302c 100644
--- a/README.md
+++ b/README.md
@@ -295,8 +295,6 @@ verified scope.
## Meet Lumi
-
-
**Lumi is Weave's firefly guide.** Translucent mint wings, a forest-green
body, and a warm amber lantern bring Weave's colors to life. The lantern
represents a clear next step through a complex process.
@@ -304,7 +302,7 @@ represents a clear next step through a complex process.
You will find Lumi throughout the documentation and in Studio. Open the
**Ask Lumi** panel for assistance, and [configure its model](docs/guides/lumi.md)
separately from workflow AI tasks. In diagrams,
-**Lumi's takeaway** highlights the main idea to remember before moving on.
+the **Takeaway** band highlights the main idea to remember before moving on.
Start with the [visual guide](docs/visual-guide.md) to explore the platform together.
## Contribute and learn more
diff --git a/assets/brand/firefly-icon-small.svg b/assets/brand/firefly-icon-small.svg
new file mode 100644
index 00000000..8a3d71fd
--- /dev/null
+++ b/assets/brand/firefly-icon-small.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/assets/brand/firefly-icon.svg b/assets/brand/firefly-icon.svg
new file mode 100644
index 00000000..4c52c208
--- /dev/null
+++ b/assets/brand/firefly-icon.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/assets/brand/firefly-mark.svg b/assets/brand/firefly-mark.svg
new file mode 100644
index 00000000..6d4d18b9
--- /dev/null
+++ b/assets/brand/firefly-mark.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/assets/brand/weave-lockup-color.svg b/assets/brand/weave-lockup-color.svg
new file mode 100644
index 00000000..175fa458
--- /dev/null
+++ b/assets/brand/weave-lockup-color.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/assets/brand/weave-lockup-reversed.svg b/assets/brand/weave-lockup-reversed.svg
new file mode 100644
index 00000000..58ba0519
--- /dev/null
+++ b/assets/brand/weave-lockup-reversed.svg
@@ -0,0 +1,2 @@
+
+Firefly WeaveThe Firefly logo, a hairline separator and the word Weave, in paper on a transparent background.
diff --git a/assets/brand/weave-lockup-small-reversed.svg b/assets/brand/weave-lockup-small-reversed.svg
new file mode 100644
index 00000000..7816e840
--- /dev/null
+++ b/assets/brand/weave-lockup-small-reversed.svg
@@ -0,0 +1,2 @@
+
+Firefly WeaveThe Firefly logo with a solid chevron, a hairline separator and the word Weave, in paper on a transparent background.
diff --git a/assets/fonts/manrope/OFL.txt b/assets/fonts/manrope/OFL.txt
new file mode 100644
index 00000000..462280f3
--- /dev/null
+++ b/assets/fonts/manrope/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2019 The Manrope Project Authors (https://github.com/sharanda/manrope)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/assets/fonts/manrope/manrope-latin-ext-wght-normal.woff2 b/assets/fonts/manrope/manrope-latin-ext-wght-normal.woff2
new file mode 100644
index 00000000..bd24140a
Binary files /dev/null and b/assets/fonts/manrope/manrope-latin-ext-wght-normal.woff2 differ
diff --git a/assets/fonts/manrope/manrope-latin-wght-normal.woff2 b/assets/fonts/manrope/manrope-latin-wght-normal.woff2
new file mode 100644
index 00000000..71eb731d
Binary files /dev/null and b/assets/fonts/manrope/manrope-latin-wght-normal.woff2 differ
diff --git a/assets/lumi.png b/assets/lumi.png
deleted file mode 100644
index 0f6de218..00000000
Binary files a/assets/lumi.png and /dev/null differ
diff --git a/assets/lumi.svg b/assets/lumi.svg
deleted file mode 100644
index 8319101b..00000000
--- a/assets/lumi.svg
+++ /dev/null
@@ -1,67 +0,0 @@
-
-
-Lumi, the Firefly Weave guide
-A forest-green firefly with a softly sculpted face, attentive eyes, folded mint wings, and a warm golden lantern. The angular wings echo the woven Weave symbol.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/README.md b/docs/README.md
index 08d3f323..9f86403e 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -18,11 +18,8 @@ SPDX-License-Identifier: Apache-2.0
# Learn and use Firefly Weave
-{ .lumi-guide }
-
-Meet **Lumi**, your guide through Firefly Weave. Start small: create a workflow,
-see how it runs, then connect it to your systems and your people.
-[Meet the mascot](visual-assets.md#meet-lumi).
+Start small: create a workflow, see how it runs, then connect it to your
+systems and your people.
Firefly Weave is a durable workflow orchestration and integration platform with
human tasks. You describe a business process once, as a **workflow**. Weave runs
diff --git a/docs/contributing/source-inventory.toml b/docs/contributing/source-inventory.toml
index bfb8492e..c586d825 100644
--- a/docs/contributing/source-inventory.toml
+++ b/docs/contributing/source-inventory.toml
@@ -17,22 +17,173 @@
version = 1
[[exceptions]]
-path = "assets/lumi.png"
-kind = "commentless"
-reason = "Apache-licensed Lumi mascot master documented in docs/visual-assets.md; PNG cannot carry a source comment header."
-copyright = "Copyright 2026 Firefly Software Foundation."
-author = "Firefly Software Foundation"
-license = "Apache-2.0"
+path = "assets/brand/weave-lockup-reversed.svg"
+kind = "third-party"
+source = "Firefly Brand Kit 2.0.0 (private), scripts/logo.mjs compact logo, composed by scripts/brand/weave-lockup.mjs"
+reason = "Firefly trademark artwork used with permission; not Apache-2.0; see NOTICE."
+copyright = "Copyright 2026 Firefly Software Solutions Inc."
+license = "LicenseRef-Firefly-Marks"
+
+[[exceptions]]
+path = "assets/brand/weave-lockup-color.svg"
+kind = "third-party"
+source = "Firefly Brand Kit 2.0.0 (private), scripts/logo.mjs compact logo, composed by scripts/brand/weave-lockup.mjs"
+reason = "Firefly trademark artwork used with permission; not Apache-2.0; see NOTICE."
+copyright = "Copyright 2026 Firefly Software Solutions Inc."
+license = "LicenseRef-Firefly-Marks"
+
+[[exceptions]]
+path = "assets/brand/weave-lockup-small-reversed.svg"
+kind = "third-party"
+source = "Firefly Brand Kit 2.0.0 (private), scripts/logo.mjs compact logo with a solid chevron, composed by scripts/brand/weave-lockup.mjs"
+reason = "Firefly trademark artwork used with permission; not Apache-2.0; see NOTICE."
+copyright = "Copyright 2026 Firefly Software Solutions Inc."
+license = "LicenseRef-Firefly-Marks"
+
+[[exceptions]]
+path = "assets/brand/firefly-icon.svg"
+kind = "third-party"
+source = "Firefly Brand Kit 2.0.0 (private), scripts/logo.mjs icon(), written by scripts/brand/weave-lockup.mjs"
+reason = "Firefly trademark artwork used with permission; not Apache-2.0; see NOTICE."
+copyright = "Copyright 2026 Firefly Software Solutions Inc."
+license = "LicenseRef-Firefly-Marks"
+
+[[exceptions]]
+path = "assets/brand/firefly-icon-small.svg"
+kind = "third-party"
+source = "Firefly Brand Kit 2.0.0 (private), scripts/logo.mjs icon({ solid: true }), written by scripts/brand/weave-lockup.mjs"
+reason = "Firefly trademark artwork used with permission; not Apache-2.0; see NOTICE."
+copyright = "Copyright 2026 Firefly Software Solutions Inc."
+license = "LicenseRef-Firefly-Marks"
+
+[[exceptions]]
+path = "assets/brand/firefly-mark.svg"
+kind = "third-party"
+source = "Firefly Brand Kit 2.0.0 (private), scripts/logo.mjs icon({ background: none }), written by scripts/brand/weave-lockup.mjs"
+reason = "Firefly trademark artwork used with permission; not Apache-2.0; see NOTICE."
+copyright = "Copyright 2026 Firefly Software Solutions Inc."
+license = "LicenseRef-Firefly-Marks"
+
+[[exceptions]]
+path = "studio/public/assets/weave-lockup-reversed.svg"
+kind = "third-party"
+source = "Byte-identical copy of assets/brand/weave-lockup-reversed.svg"
+reason = "Firefly trademark artwork used with permission; not Apache-2.0; see NOTICE."
+copyright = "Copyright 2026 Firefly Software Solutions Inc."
+license = "LicenseRef-Firefly-Marks"
+
+[[exceptions]]
+path = "studio/public/assets/weave-lockup-small-reversed.svg"
+kind = "third-party"
+source = "Byte-identical copy of assets/brand/weave-lockup-small-reversed.svg"
+reason = "Firefly trademark artwork used with permission; not Apache-2.0; see NOTICE."
+copyright = "Copyright 2026 Firefly Software Solutions Inc."
+license = "LicenseRef-Firefly-Marks"
+
+[[exceptions]]
+path = "studio/public/assets/firefly-mark.svg"
+kind = "third-party"
+source = "Byte-identical copy of assets/brand/firefly-mark.svg"
+reason = "Firefly trademark artwork used with permission; not Apache-2.0; see NOTICE."
+copyright = "Copyright 2026 Firefly Software Solutions Inc."
+license = "LicenseRef-Firefly-Marks"
+
+[[exceptions]]
+path = "studio/public/favicon.svg"
+kind = "third-party"
+source = "Byte-identical copy of assets/brand/firefly-icon.svg"
+reason = "Firefly trademark artwork used with permission; not Apache-2.0; see NOTICE."
+copyright = "Copyright 2026 Firefly Software Solutions Inc."
+license = "LicenseRef-Firefly-Marks"
+
+[[exceptions]]
+path = "studio/public/favicon.ico"
+kind = "third-party"
+source = "Firefly Brand Kit 2.0.0 (private), npm run build -- --only logo, 02-Logo/favicon/favicon.ico (16, 32, 48)"
+reason = "Firefly trademark artwork used with permission; not Apache-2.0; see NOTICE."
+copyright = "Copyright 2026 Firefly Software Solutions Inc."
+license = "LicenseRef-Firefly-Marks"
[[exceptions]]
-path = "studio/public/assets/lumi.png"
+path = "studio/public/apple-touch-icon.png"
+kind = "third-party"
+source = "Firefly Brand Kit 2.0.0 (private), npm run build -- --only logo, 02-Logo/favicon/apple-touch-icon.png (180)"
+reason = "Firefly trademark artwork used with permission; not Apache-2.0; see NOTICE."
+copyright = "Copyright 2026 Firefly Software Solutions Inc."
+license = "LicenseRef-Firefly-Marks"
+
+[[exceptions]]
+path = "assets/fonts/manrope/manrope-latin-wght-normal.woff2"
+kind = "third-party"
+source = "@fontsource-variable/manrope 5.3.0, files/manrope-latin-wght-normal.woff2"
+reason = "Manrope variable font file (binary), unmodified; see NOTICE."
+copyright = "Copyright 2019 The Manrope Project Authors (https://github.com/sharanda/manrope)"
+license = "OFL-1.1"
+
+[[exceptions]]
+path = "assets/fonts/manrope/manrope-latin-ext-wght-normal.woff2"
+kind = "third-party"
+source = "@fontsource-variable/manrope 5.3.0, files/manrope-latin-ext-wght-normal.woff2"
+reason = "Manrope variable font file (binary), unmodified; see NOTICE."
+copyright = "Copyright 2019 The Manrope Project Authors (https://github.com/sharanda/manrope)"
+license = "OFL-1.1"
+
+[[exceptions]]
+path = "studio/public/fonts/manrope/manrope-latin-wght-normal.woff2"
+kind = "third-party"
+source = "@fontsource-variable/manrope 5.3.0, files/manrope-latin-wght-normal.woff2"
+reason = "Manrope variable font file (binary), unmodified; see NOTICE."
+copyright = "Copyright 2019 The Manrope Project Authors (https://github.com/sharanda/manrope)"
+license = "OFL-1.1"
+
+[[exceptions]]
+path = "studio/public/fonts/manrope/manrope-latin-ext-wght-normal.woff2"
+kind = "third-party"
+source = "@fontsource-variable/manrope 5.3.0, files/manrope-latin-ext-wght-normal.woff2"
+reason = "Manrope variable font file (binary), unmodified; see NOTICE."
+copyright = "Copyright 2019 The Manrope Project Authors (https://github.com/sharanda/manrope)"
+license = "OFL-1.1"
+
+[[exceptions]]
+path = "assets/fonts/manrope/OFL.txt"
+kind = "third-party"
+source = "@fontsource-variable/manrope 5.3.0, LICENSE"
+reason = "SIL Open Font License text that must accompany the Manrope files."
+copyright = "Copyright 2019 The Manrope Project Authors (https://github.com/sharanda/manrope)"
+license = "OFL-1.1"
+
+[[exceptions]]
+path = "studio/public/licenses/manrope-OFL.txt"
+kind = "third-party"
+source = "Byte-identical copy of assets/fonts/manrope/OFL.txt"
+reason = "SIL Open Font License text shipped in the Studio bundle with the Manrope files."
+copyright = "Copyright 2019 The Manrope Project Authors (https://github.com/sharanda/manrope)"
+license = "OFL-1.1"
+
+[[exceptions]]
+path = "studio/public/licenses/NOTICE.txt"
kind = "commentless"
-source = "assets/lumi.png"
-reason = "Byte-identical Studio distribution copy of the Apache-licensed Lumi mascot master."
+reason = "Byte-identical copy of NOTICE shipped in the Studio bundle; a plain-text notice cannot carry a comment header."
copyright = "Copyright 2026 Firefly Software Foundation."
author = "Firefly Software Foundation"
license = "Apache-2.0"
+[[exceptions]]
+path = "studio/public/licenses/lucide-LICENSE.txt"
+kind = "third-party"
+source = "lucide-static 1.52.0, LICENSE"
+reason = "ISC and MIT license text that must accompany the Lucide-derived icon geometry."
+copyright = "Copyright (c) 2026 Lucide Icons and Contributors; Copyright (c) 2013-present Cole Bemis"
+license = "ISC AND MIT"
+
+[[exceptions]]
+path = "studio/src/app/icon-data.ts"
+kind = "third-party"
+source = "lucide-static 1.52.0 via studio/scripts/build-icons.mjs"
+reason = "Generated Lucide icon geometry; regenerate with npm run icons, never edit."
+copyright = "Copyright (c) 2026 Lucide Icons and Contributors; Copyright (c) 2013-present Cole Bemis"
+license = "ISC AND MIT"
+
[[exceptions]]
path = "tests/fixtures/definitions/invalid/duplicate-key.yaml"
kind = "immutable-fixture"
diff --git a/docs/diagrams/authoring-diagnostic-loop.svg b/docs/diagrams/authoring-diagnostic-loop.svg
index a8b3e8b3..ab342302 100644
--- a/docs/diagrams/authoring-diagnostic-loop.svg
+++ b/docs/diagrams/authoring-diagnostic-loop.svg
@@ -79,55 +79,9 @@ SPDX-License-Identifier: Apache-2.0
What this tells youMove forward only when the boundary you need has passed. Keep source
-location and diagnostic code together when explaining a failure.
+location and diagnostic code together when explaining a failure.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Fix the first reported boundary, then check the definition again.
+Takeaway
+Fix the first reported boundary, then check the definition again.
diff --git a/docs/diagrams/authoring-execution-boundaries.svg b/docs/diagrams/authoring-execution-boundaries.svg
index 0adcfd4f..47596fef 100644
--- a/docs/diagrams/authoring-execution-boundaries.svg
+++ b/docs/diagrams/authoring-execution-boundaries.svg
@@ -83,55 +83,9 @@ SPDX-License-Identifier: Apache-2.0
Compilation is one boundaryA compiled contract describes behavior. Separate service checks decide
-whether a caller can create or execute live resources.
+whether a caller can create or execute live resources.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Choose your boundary: offline tools, authenticated API, or explicit embedding.
+Takeaway
+Choose your boundary: offline tools, authenticated API, or explicit embedding.
diff --git a/docs/diagrams/authoring-host-sequence.svg b/docs/diagrams/authoring-host-sequence.svg
index cff7ce34..15808a82 100644
--- a/docs/diagrams/authoring-host-sequence.svg
+++ b/docs/diagrams/authoring-host-sequence.svg
@@ -63,55 +63,9 @@ SPDX-License-Identifier: Apache-2.0
Keep IDs and retry keys with the requestThe workspace scope selects resources; your token and grants supply authority.A lost mutation response leaves the outcome unknown: retry with the exact
-request and the same key.
+request and the same key.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Carry the returned IDs and original retry keys into the next request.
+Takeaway
+Carry the returned IDs and original retry keys into the next request.
diff --git a/docs/diagrams/authoring-openapi-directions.svg b/docs/diagrams/authoring-openapi-directions.svg
index aae34e9a..24479613 100644
--- a/docs/diagrams/authoring-openapi-directions.svg
+++ b/docs/diagrams/authoring-openapi-directions.svg
@@ -76,55 +76,9 @@ SPDX-License-Identifier: Apache-2.0
Both directions run offlineNeither one starts services, resolves secrets, or calls a provider.
-Imported Actions still need publication, a connection, and an activation.
+Imported Actions still need publication, a connection, and an activation.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Export describes Weave’s API; import turns another API into reviewed Actions.
+Takeaway
+Export describes Weave’s API; import turns another API into reviewed Actions.
diff --git a/docs/diagrams/authoring-schema-boundaries.svg b/docs/diagrams/authoring-schema-boundaries.svg
index be7d346d..ed884ab8 100644
--- a/docs/diagrams/authoring-schema-boundaries.svg
+++ b/docs/diagrams/authoring-schema-boundaries.svg
@@ -83,55 +83,9 @@ SPDX-License-Identifier: Apache-2.0
Classified data is checked tooPresent values marked x-secret or writeOnly are rejected, including null and
-empty values. Optional fields may be absent; defaults do not insert data.
+empty values. Optional fields may be absent; defaults do not insert data.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Check the data at each boundary, not just the schema that describes it.
+Takeaway
+Check the data at each boundary, not just the schema that describes it.
diff --git a/docs/diagrams/authoring-simulation-controls.svg b/docs/diagrams/authoring-simulation-controls.svg
index 8b6edd6a..e0d57a6d 100644
--- a/docs/diagrams/authoring-simulation-controls.svg
+++ b/docs/diagrams/authoring-simulation-controls.svg
@@ -74,55 +74,9 @@ SPDX-License-Identifier: Apache-2.0
Two clocks have different jobsVirtual time governs timers. Server debug sessions expire after one real
-hour; saving progress and virtual time does not extend that lifetime.
+hour; saving progress and virtual time does not extend that lifetime.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Move virtual time explicitly; simulations never call live providers.
+Takeaway
+Move virtual time explicitly; simulations never call live providers.
diff --git a/docs/diagrams/authoring-worker-lifecycle.svg b/docs/diagrams/authoring-worker-lifecycle.svg
index e5d250a7..82d35520 100644
--- a/docs/diagrams/authoring-worker-lifecycle.svg
+++ b/docs/diagrams/authoring-worker-lifecycle.svg
@@ -89,55 +89,9 @@ SPDX-License-Identifier: Apache-2.0
Plan for at-least-once deliveryA lost response after an effect can leave its outcome unknown. A releasealone cannot execute a task; the SDK renews authority and delivers the
-attempt outcome.
+attempt outcome.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-A current lease permits one attempt; old proofs cannot complete a new one.
+Takeaway
+A current lease permits one attempt; old proofs cannot complete a new one.
diff --git a/docs/diagrams/capability-evidence.svg b/docs/diagrams/capability-evidence.svg
index 681f394b..262c7076 100644
--- a/docs/diagrams/capability-evidence.svg
+++ b/docs/diagrams/capability-evidence.svg
@@ -52,55 +52,9 @@ SPDX-License-Identifier: Apache-2.0
A local acknowledgment does not prove real delivery.Read the capability matrix together with the connector guide.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Match the claim to its evidence: source, local tests, or real provider delivery.
+Takeaway
+Match the claim to its evidence: source, local tests, or real provider delivery.
diff --git a/docs/diagrams/cli-installation.svg b/docs/diagrams/cli-installation.svg
index 5eb0b5cc..96635945 100644
--- a/docs/diagrams/cli-installation.svg
+++ b/docs/diagrams/cli-installation.svg
@@ -52,55 +52,9 @@ SPDX-License-Identifier: Apache-2.0
The command changes only after the checks pass.A failed check leaves your previous CLI selected; older versions stay for rollback.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Keep the current CLI selected until the new installation passes its checks.
+Takeaway
+Keep the current CLI selected until the new installation passes its checks.
diff --git a/docs/diagrams/cloud-deployment.svg b/docs/diagrams/cloud-deployment.svg
index efff3100..53cf8179 100644
--- a/docs/diagrams/cloud-deployment.svg
+++ b/docs/diagrams/cloud-deployment.svg
@@ -78,55 +78,9 @@ SPDX-License-Identifier: Apache-2.0
Pushing an image grants no Weave authority.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Image delivery, database migration, and worker admission are separate jobs.
+Takeaway
+Image delivery, database migration, and worker admission are separate jobs.
diff --git a/docs/diagrams/compiler-execution.svg b/docs/diagrams/compiler-execution.svg
index 03111614..8c6b7094 100644
--- a/docs/diagrams/compiler-execution.svg
+++ b/docs/diagrams/compiler-execution.svg
@@ -79,55 +79,9 @@ SPDX-License-Identifier: Apache-2.0
An external call can happen before completion is recorded.Use provider idempotency or reconciliation. Simulation and recorded replay do not call providers.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Compilation checks meaning; activation checks authority and readiness.
+Takeaway
+Compilation checks meaning; activation checks authority and readiness.
diff --git a/docs/diagrams/connect-and-sign-in.svg b/docs/diagrams/connect-and-sign-in.svg
index e04e18ec..6ba39b93 100644
--- a/docs/diagrams/connect-and-sign-in.svg
+++ b/docs/diagrams/connect-and-sign-in.svg
@@ -85,55 +85,9 @@ SPDX-License-Identifier: Apache-2.0
the local tokens and asks the provider to revokethe sign-in. The saved platform stays until youremove it in Studio or with weave auth remove.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-The saved platform holds no secrets. Tokens stay in the credential store you chose.
+Takeaway
+The saved platform holds no secrets. Tokens stay in the credential store you chose.
diff --git a/docs/diagrams/data-model-definitions.svg b/docs/diagrams/data-model-definitions.svg
index d66c2360..2df8eace 100644
--- a/docs/diagrams/data-model-definitions.svg
+++ b/docs/diagrams/data-model-definitions.svg
@@ -85,55 +85,9 @@ SPDX-License-Identifier: Apache-2.0
Migrations 0004–0005. Activations also reference environments.Drafts and mutation-idempotency records belong to projects; worker grants add dependencies.These are selected SQL relationships. JSON-held pins are not invented foreign keys.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Versions stay immutable while activations choose where to use them.
+Takeaway
+Versions stay immutable while activations choose where to use them.
diff --git a/docs/diagrams/data-model-integrations.svg b/docs/diagrams/data-model-integrations.svg
index d5c70873..56b03ea9 100644
--- a/docs/diagrams/data-model-integrations.svg
+++ b/docs/diagrams/data-model-integrations.svg
@@ -96,55 +96,9 @@ SPDX-License-Identifier: Apache-2.0
Migrations 0017–0020. Deliveries also reference subscriptions; provider sources referencesource bindings and one activation or run. WhatsApp observations join source, fact, andconnection revision. Broker receipts have separate FKs (0016). This view does not decide retention.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Event, receipt, and delivery-attempt identities describe different facts.
+Takeaway
+Event, receipt, and delivery-attempt identities describe different facts.
diff --git a/docs/diagrams/data-model-runtime.svg b/docs/diagrams/data-model-runtime.svg
index 75691b3f..e3959ad2 100644
--- a/docs/diagrams/data-model-runtime.svg
+++ b/docs/diagrams/data-model-runtime.svg
@@ -85,55 +85,9 @@ SPDX-License-Identifier: Apache-2.0
Migrations 0006–0007. Generations distinguish attempts and fence stale completions.Runs also own events, step instances, deadlines, and signal receipts; wait wakeups reference deadlines.History, incidents, schedules, and debug state have additional scoped ownership.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-The lease generation connects a completion to its current attempt.
+Takeaway
+The lease generation connects a completion to its current attempt.
diff --git a/docs/diagrams/definition-lifecycle.svg b/docs/diagrams/definition-lifecycle.svg
index c7c8461c..e250e494 100644
--- a/docs/diagrams/definition-lifecycle.svg
+++ b/docs/diagrams/definition-lifecycle.svg
@@ -61,55 +61,9 @@ SPDX-License-Identifier: Apache-2.0
Run BCustomer B’s inputIts own accepted state + history
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-New inputs create new runs; source edits do not rewrite existing runs.
+Takeaway
+New inputs create new runs; source edits do not rewrite existing runs.
diff --git a/docs/diagrams/documentation-contribution.svg b/docs/diagrams/documentation-contribution.svg
index 1e36be9f..6eab2c03 100644
--- a/docs/diagrams/documentation-contribution.svg
+++ b/docs/diagrams/documentation-contribution.svg
@@ -53,55 +53,9 @@ SPDX-License-Identifier: Apache-2.0
Render at article width; check labels, arrows, and captions.Follow the chapter using only its stated prerequisites.A passing link check cannot prove accuracy. A diagram must not invent a guarantee.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Run the example and inspect the picture before publishing the guide.
+Takeaway
+Run the example and inspect the picture before publishing the guide.
diff --git a/docs/diagrams/echo-data-flow.svg b/docs/diagrams/echo-data-flow.svg
index 665b2af9..5ae8a47d 100644
--- a/docs/diagrams/echo-data-flow.svg
+++ b/docs/diagrams/echo-data-flow.svg
@@ -53,55 +53,9 @@ SPDX-License-Identifier: Apache-2.0
Identity + grants + activationPostgreSQL · result: state.outputOnly the durable run creates server-side state and history.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-The same message can be simulated locally or saved in a durable run.
+Takeaway
+The same message can be simulated locally or saved in a durable run.
diff --git a/docs/diagrams/execution-lifecycle.svg b/docs/diagrams/execution-lifecycle.svg
index dd846106..0d01b371 100644
--- a/docs/diagrams/execution-lifecycle.svg
+++ b/docs/diagrams/execution-lifecycle.svg
@@ -45,55 +45,9 @@ SPDX-License-Identifier: Apache-2.0
Only finished runs can be archived. Purge needs an archived run and the run.purgepermission; live leases or retained references block it, and nothing is partially deleted.A small deletion receipt and access audit remain. External systems and backups are managed separately.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Search by key, then act on one exact run ID.
+Takeaway
+Search by key, then act on one exact run ID.
diff --git a/docs/diagrams/identity-provider-setup.svg b/docs/diagrams/identity-provider-setup.svg
index a60e5b21..f4fb004d 100644
--- a/docs/diagrams/identity-provider-setup.svg
+++ b/docs/diagrams/identity-provider-setup.svg
@@ -105,55 +105,9 @@ SPDX-License-Identifier: Apache-2.0
Local development: weave platform setup prepares its own Keycloak forsteps 1 to 4, and weave platform user links a person with grants (step 5).
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Your provider proves identity. Weave grants decide what happens next.
+Takeaway
+Your provider proves identity. Weave grants decide what happens next.
diff --git a/docs/diagrams/integration-delivery.svg b/docs/diagrams/integration-delivery.svg
index 88dac204..7e7b41b8 100644
--- a/docs/diagrams/integration-delivery.svg
+++ b/docs/diagrams/integration-delivery.svg
@@ -62,55 +62,9 @@ SPDX-License-Identifier: Apache-2.0
Provider-specific records are evidence, not delivery guarantees.Teams retains reference lifecycle; WhatsApp retains status facts; Telegram retains update receipts.At-least-once delivery remains. Broker offsets do not create a global effects transaction.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-An admission receipt and a provider acknowledgment prove different things.
+Takeaway
+An admission receipt and a provider acknowledgment prove different things.
diff --git a/docs/diagrams/integrations-admission.svg b/docs/diagrams/integrations-admission.svg
index 77ae7da0..68e04ead 100644
--- a/docs/diagrams/integrations-admission.svg
+++ b/docs/diagrams/integrations-admission.svg
@@ -78,55 +78,9 @@ SPDX-License-Identifier: Apache-2.0
Check each layer separatelyPackage validation checks contracts. Native tests check composition. Neitherproves a provider account, live access, or successful delivery. Credentials
-stay outside workflow input.
+stay outside workflow input.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Installed code still needs published contracts and permission to execute.
+Takeaway
+Installed code still needs published contracts and permission to execute.
diff --git a/docs/diagrams/integrations-directions.svg b/docs/diagrams/integrations-directions.svg
index 7c35bd33..d6edd1be 100644
--- a/docs/diagrams/integrations-directions.svg
+++ b/docs/diagrams/integrations-directions.svg
@@ -88,55 +88,9 @@ SPDX-License-Identifier: Apache-2.0
An acknowledgment is evidence at one boundaryOutbound retries keep the same event body and identity. Every lane needscurrent scoped authority; no receipt alone proves a finished workflow or
-delivery. Calling an API from a workflow step is an Action, not shown here.
+delivery. Calling an API from a workflow step is an Action, not shown here.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Read an acknowledgment in the context of the boundary that produced it.
+Takeaway
+Read an acknowledgment in the context of the boundary that produced it.
diff --git a/docs/diagrams/integrations-http-profile.svg b/docs/diagrams/integrations-http-profile.svg
index 7fc04c76..48b9e35f 100644
--- a/docs/diagrams/integrations-http-profile.svg
+++ b/docs/diagrams/integrations-http-profile.svg
@@ -82,55 +82,9 @@ SPDX-License-Identifier: Apache-2.0
Keep policy and data separateThe native executor enforces the pinned operation. A workflow suppliesdeclared values within that boundary.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Keep deployment policy fixed and pass only the permitted per-run values.
+Takeaway
+Keep deployment policy fixed and pass only the permitted per-run values.
diff --git a/docs/diagrams/integrations-incidents-replay.svg b/docs/diagrams/integrations-incidents-replay.svg
index 9c43fc0d..f4731620 100644
--- a/docs/diagrams/integrations-incidents-replay.svg
+++ b/docs/diagrams/integrations-incidents-replay.svg
@@ -77,55 +77,9 @@ SPDX-License-Identifier: Apache-2.0
Replay cannot fill missing evidenceauthorization_verified remains false. Clear the last blocking incident tocontinue. Whole-run retry needs a terminal parent and creates new operation
-keys with possible new effects.
+keys with possible new effects.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Inspect the recorded attempt before deciding whether a retry is safe.
+Takeaway
+Inspect the recorded attempt before deciding whether a retry is safe.
diff --git a/docs/diagrams/integrations-kafka-receipts.svg b/docs/diagrams/integrations-kafka-receipts.svg
index a543e5e8..e8cf9028 100644
--- a/docs/diagrams/integrations-kafka-receipts.svg
+++ b/docs/diagrams/integrations-kafka-receipts.svg
@@ -66,55 +66,9 @@ SPDX-License-Identifier: Apache-2.0
Rejected records follow explicit policyReceipt policy may advance after rejection is persisted. Halt policy leaves
-the rejected offset uncommitted.
+the rejected offset uncommitted.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Keep the durable receipt before advancing the broker offset.
+Takeaway
+Keep the durable receipt before advancing the broker offset.
diff --git a/docs/diagrams/integrations-messaging.svg b/docs/diagrams/integrations-messaging.svg
index a43e479e..ae827fd4 100644
--- a/docs/diagrams/integrations-messaging.svg
+++ b/docs/diagrams/integrations-messaging.svg
@@ -96,55 +96,9 @@ SPDX-License-Identifier: Apache-2.0
Provider acceptance is not deliveryUnknown sends need reconciliation. These profiles and local fixtures do not
-certify a live account or installation.
+certify a live account or installation.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Preserve each provider’s own reference, receipt and status identities.
+Takeaway
+Preserve each provider’s own reference, receipt and status identities.
diff --git a/docs/diagrams/integrations-openapi-review.svg b/docs/diagrams/integrations-openapi-review.svg
index c5a6f7d2..8d07ee64 100644
--- a/docs/diagrams/integrations-openapi-review.svg
+++ b/docs/diagrams/integrations-openapi-review.svg
@@ -79,55 +79,9 @@ SPDX-License-Identifier: Apache-2.0
Import remains an offline operationIt allocates no live resource IDs and makes no provider request. Treat thegenerated files as reviewable starting material.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Imported metadata is a starting point for review, not permission to run.
+Takeaway
+Imported metadata is a starting point for review, not permission to run.
diff --git a/docs/diagrams/integrations-sql-transaction.svg b/docs/diagrams/integrations-sql-transaction.svg
index 5ee8a28c..5fc9dcf4 100644
--- a/docs/diagrams/integrations-sql-transaction.svg
+++ b/docs/diagrams/integrations-sql-transaction.svg
@@ -76,55 +76,9 @@ SPDX-License-Identifier: Apache-2.0
Two databases mean two boundariesA durable Weave task record does not make the external database part of
-Weave’s transaction.
+Weave’s transaction.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-An uncertain SQL commit needs reconciliation before another attempt.
+Takeaway
+An uncertain SQL commit needs reconciliation before another attempt.
diff --git a/docs/diagrams/integrations-time-and-signals.svg b/docs/diagrams/integrations-time-and-signals.svg
index fb1f08e3..facfa8e2 100644
--- a/docs/diagrams/integrations-time-and-signals.svg
+++ b/docs/diagrams/integrations-time-and-signals.svg
@@ -82,55 +82,9 @@ SPDX-License-Identifier: Apache-2.0
Use the right clock as evidenceDatabase time governs live admission. Offline replay uses the recorded
-receipts and deadline facts, not the current wall clock.
+receipts and deadline facts, not the current wall clock.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Schedules create runs; waits and signals continue an existing run.
+Takeaway
+Schedules create runs; waits and signals continue an existing run.
diff --git a/docs/diagrams/no-code-integration.svg b/docs/diagrams/no-code-integration.svg
index c0a50df6..cf974a6c 100644
--- a/docs/diagrams/no-code-integration.svg
+++ b/docs/diagrams/no-code-integration.svg
@@ -110,55 +110,9 @@ SPDX-License-Identifier: Apache-2.0
Only HTTPS origins listed on the connection; redirects are not followed. Private and loopback addressesneed a network the operator allows; link-local and known metadata addresses are always refused.Responses stop at 1 MiB and calls at 30 seconds. Secrets resolve by handle for the granted release only.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-You describe the request. The platform runs fixed code, only to destinations you allowed.
+Takeaway
+You describe the request. The platform runs fixed code, only to destinations you allowed.
diff --git a/docs/diagrams/operations-authority.svg b/docs/diagrams/operations-authority.svg
index 8f801ce5..03632370 100644
--- a/docs/diagrams/operations-authority.svg
+++ b/docs/diagrams/operations-authority.svg
@@ -81,55 +81,9 @@ SPDX-License-Identifier: Apache-2.0
Keep configuration ownership explicitA private env file does not bypass authorization. Use the right identity for
-the process and the exact scope for each operation.
+the process and the exact scope for each operation.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Give each process the identity and credentials its own job requires.
+Takeaway
+Give each process the identity and credentials its own job requires.
diff --git a/docs/diagrams/operations-evidence.svg b/docs/diagrams/operations-evidence.svg
index 82d68177..6b48c1ad 100644
--- a/docs/diagrams/operations-evidence.svg
+++ b/docs/diagrams/operations-evidence.svg
@@ -76,55 +76,9 @@ SPDX-License-Identifier: Apache-2.0
Explicit WEAVE_TELEMETRY enables owned providers and bounded export to acollector. It is off by default; dropped spans do not erase durable facts orprove an effect never occurred.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Find the failed boundary before retrying a request or external effect.
+Takeaway
+Find the failed boundary before retrying a request or external effect.
diff --git a/docs/diagrams/operations-restore.svg b/docs/diagrams/operations-restore.svg
index 56e7d13f..ee6a8228 100644
--- a/docs/diagrams/operations-restore.svg
+++ b/docs/diagrams/operations-restore.svg
@@ -77,55 +77,9 @@ SPDX-License-Identifier: Apache-2.0
Restored leases are not newly fenced attemptsTheir original proofs and deadlines remain. A restore does not create a newfencing epoch; avoid running both deployments against the same external
-effects.
+effects.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Keep the source fenced while you verify the separate restored target.
+Takeaway
+Keep the source fenced while you verify the separate restored target.
diff --git a/docs/diagrams/operations-retention.svg b/docs/diagrams/operations-retention.svg
index f5b6d3c7..f1e394a5 100644
--- a/docs/diagrams/operations-retention.svg
+++ b/docs/diagrams/operations-retention.svg
@@ -78,55 +78,9 @@ SPDX-License-Identifier: Apache-2.0
Omitted records keep their protectionWorkflow history, provider receipts, source fences, catalogs, and outbox
-history are never deleted here; archived runs are purged one at a time.
+history are never deleted here; archived runs are purged one at a time.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Review the exact plan; applying it rechecks every candidate.
+Takeaway
+Review the exact plan; applying it rechecks every candidate.
diff --git a/docs/diagrams/operations-topology.svg b/docs/diagrams/operations-topology.svg
index c80d19de..54c698cd 100644
--- a/docs/diagrams/operations-topology.svg
+++ b/docs/diagrams/operations-topology.svg
@@ -95,55 +95,9 @@ SPDX-License-Identifier: Apache-2.0
Start the dependencies and verify readiness, then start the API andthe required admitted executors. Verify a real run, and replace aprocess deliberately when its configuration changes.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Keep process identities separate and reuse the installation’s saved receipts.
+Takeaway
+Keep process identities separate and reuse the installation’s saved receipts.
diff --git a/docs/diagrams/operations-upgrade.svg b/docs/diagrams/operations-upgrade.svg
index 6bbd4b9a..a9863515 100644
--- a/docs/diagrams/operations-upgrade.svg
+++ b/docs/diagrams/operations-upgrade.svg
@@ -84,55 +84,9 @@ SPDX-License-Identifier: Apache-2.0
All three gates must passOnly then deliberately reenable writers and monitor. A successful process
-start is not enough to accept an upgrade.
+start is not enough to accept an upgrade.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Check schema, persisted requirements and a real run before reopening writers.
+Takeaway
+Check schema, persisted requirements and a real run before reopening writers.
diff --git a/docs/diagrams/platform-in-plain-english.svg b/docs/diagrams/platform-in-plain-english.svg
index 20471687..285cc412 100644
--- a/docs/diagrams/platform-in-plain-english.svg
+++ b/docs/diagrams/platform-in-plain-english.svg
@@ -60,55 +60,9 @@ SPDX-License-Identifier: Apache-2.0
Just learning? Start with local simulation.YAML + sample input + mocks → a result, without API, database, or external calls.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-The API coordinates, the database remembers, and integration code calls other systems.
+Takeaway
+The API coordinates, the database remembers, and integration code calls other systems.
diff --git a/docs/diagrams/security-boundaries.svg b/docs/diagrams/security-boundaries.svg
index cb3ba4d4..eb3402b2 100644
--- a/docs/diagrams/security-boundaries.svg
+++ b/docs/diagrams/security-boundaries.svg
@@ -51,55 +51,9 @@ SPDX-License-Identifier: Apache-2.0
Resolve scoped secret handles only for authorized work and approved destinations.Migration owner, application, scheduler and worker identities remain separate.Provider role claims, URL IDs and compiler success do not grant permission.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Identity says who called; current scoped grants decide what they may do.
+Takeaway
+Identity says who called; current scoped grants decide what they may do.
diff --git a/docs/diagrams/sign-in-roles.svg b/docs/diagrams/sign-in-roles.svg
index 6ff0463e..d269c571 100644
--- a/docs/diagrams/sign-in-roles.svg
+++ b/docs/diagrams/sign-in-roles.svg
@@ -73,55 +73,9 @@ SPDX-License-Identifier: Apache-2.0
in its tenant, project, and environment.Provider role claims do not grant access.Hidden buttons in Studio are only a convenience.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Sign-in proves who you are. Only the platform’s grants decide what you may do.
+Takeaway
+Sign-in proves who you are. Only the platform’s grants decide what you may do.
diff --git a/docs/diagrams/system-context.svg b/docs/diagrams/system-context.svg
index d01dc978..dcc6da2b 100644
--- a/docs/diagrams/system-context.svg
+++ b/docs/diagrams/system-context.svg
@@ -71,55 +71,9 @@ SPDX-License-Identifier: Apache-2.0
Connectors run in a nativeexecutor; your own handlersrun in remote workers.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Keep workflow checks, durable coordination and external effects distinct.
+Takeaway
+Keep workflow checks, durable coordination and external effects distinct.
diff --git a/docs/diagrams/tutorial-route.svg b/docs/diagrams/tutorial-route.svg
index 36994a70..bfe689f4 100644
--- a/docs/diagrams/tutorial-route.svg
+++ b/docs/diagrams/tutorial-route.svg
@@ -65,55 +65,9 @@ SPDX-License-Identifier: Apache-2.0
Both branches depend on the running API.Continue with the CLI tutorial, then an AWS, Azure, or Google Cloud deployment.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-Get one working checkpoint before adding the next integration boundary.
+Takeaway
+Get one working checkpoint before adding the next integration boundary.
diff --git a/docs/diagrams/worker-and-operator-roles.svg b/docs/diagrams/worker-and-operator-roles.svg
index ca9bef6b..6144d128 100644
--- a/docs/diagrams/worker-and-operator-roles.svg
+++ b/docs/diagrams/worker-and-operator-roles.svg
@@ -79,53 +79,6 @@ SPDX-License-Identifier: Apache-2.0
3. An accepted result lets the engine continueA crash can repeat an attempt; use a stable operation key for external effects.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-The operator manages runs. The worker performs tasks.
+Takeaway
+The operator manages runs. The worker performs tasks.
diff --git a/docs/diagrams/worker-recovery.svg b/docs/diagrams/worker-recovery.svg
index b7d44dd4..1cc9dd30 100644
--- a/docs/diagrams/worker-recovery.svg
+++ b/docs/diagrams/worker-recovery.svg
@@ -58,55 +58,9 @@ SPDX-License-Identifier: Apache-2.0
No accepted completion? Recover according to the effect.Safe retries may get a new lease; late results stay fenced.Ambiguous non-idempotent effects need reconciliation. Cancellation is not proof of no effect.
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Lumi’s takeaway
-A missing completion does not mean the external action never happened.
+Takeaway
+A missing completion does not mean the external action never happened.
diff --git a/docs/guides/lumi.md b/docs/guides/lumi.md
index b15870e1..a42bcfaa 100644
--- a/docs/guides/lumi.md
+++ b/docs/guides/lumi.md
@@ -18,8 +18,6 @@ SPDX-License-Identifier: Apache-2.0
# Lumi assistant
-{ .lumi-guide }
-
Lumi is an optional Studio assistant for explaining and drafting Weave definitions.
It returns text and source proposals for review. It cannot publish, activate, run,
delete, upload, or modify resources. Proposals still need the normal compiler and
diff --git a/docs/stylesheets/weave.css b/docs/stylesheets/weave.css
index 0b6e7b0d..891400f0 100644
--- a/docs/stylesheets/weave.css
+++ b/docs/stylesheets/weave.css
@@ -72,9 +72,3 @@ SPDX-License-Identifier: Apache-2.0
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
}
-
-/* Keep the companion illustration small enough to leave instructions in focus. */
-.md-typeset img[src$="lumi.png"] { width: 10rem; height: auto; }
-
-.md-typeset img.lumi-guide { float: right; width: 8rem; margin: 0 0 1rem 1rem; }
-.md-typeset img.lumi-guide ~ h2 { clear: both; }
diff --git a/docs/visual-assets.md b/docs/visual-assets.md
index 8386a679..4d53c779 100644
--- a/docs/visual-assets.md
+++ b/docs/visual-assets.md
@@ -16,7 +16,7 @@ Author: Firefly Software Foundation
SPDX-License-Identifier: Apache-2.0
-->
-# Use the Weave logo, Lumi, and diagrams
+# Use the Weave logo and diagrams
Use this page when you place the Weave logo in a document or slide, or when you
draw or change a diagram in this documentation. It tells you which file to use
@@ -25,11 +25,13 @@ diagram follows, and how to check a change at its real size. It takes about ten
minutes to read. To edit, you need a text editor or a standards-compliant SVG
editor, and a Chromium-based browser to check the result.
-The brand assets are distributed under the Apache License 2.0. Logos and
-technical diagrams use editable SVG source, with no scripts, remote fonts,
-embedded raster images, or `foreignObject`. Lumi is an image-generated PNG
-with a transparent background. The checked-in image is the master asset;
-building Studio or the documentation does not call an image-generation service.
+Weave's own logos and technical diagrams are distributed under the Apache
+License 2.0. The Firefly marks are not: they are trademarks of Firefly Software
+Solutions Inc., used with permission (see `NOTICE`). The source inventory,
+`docs/contributing/source-inventory.toml`, lists every file that carries them:
+`assets/brand/`, the Studio copies and the favicons. Logos and technical
+diagrams use editable SVG source, with no scripts, remote fonts, embedded raster
+images, or `foreignObject`.
## Choose the right asset
@@ -39,7 +41,6 @@ building Studio or the documentation does not call an image-generation service.
| [Monochrome symbol](../assets/weave-logo-mono.svg) | One-color reproduction on light surfaces | 256 × 256 |
| [Reversed symbol](../assets/weave-logo-reversed.svg) | The white mark on dark surfaces; the website header uses it | 256 × 256 |
| [Banner](../assets/banner.svg) | The README and project overview; it has an opaque white background | 1120 × 280 |
-| [Lumi PNG](../assets/lumi.png) | Studio, documentation, slides, and diagram companion artwork; see [Meet Lumi](#meet-lumi) | 1254 × 1254, transparent |
| Badges for the [license](../assets/badges/license.svg), [Python version](../assets/badges/python.svg), and [maturity](../assets/badges/alpha.svg) | Small labels that state facts from the source tree. There is no CI, release, coverage, or live-provider badge | 28 pixels high |
| Technical diagrams in `docs/diagrams/` | Explaining how Weave works; the [visual guide](visual-guide.md) lists every one | 960 or 1120 pixels wide |
@@ -102,7 +103,7 @@ structural ones.
| Arial for text (`Arial, Helvetica, sans-serif`), Menlo or Consolas for code | These fonts are installed almost everywhere, so labels do not reflow |
| Text at least 16 pixels | Labels stay legible at 100% zoom |
| Forest `#173D34` and jade `#367D68` on mist `#EEF4F0`, with warm `#FFF7E5` for cautions | Diagrams look like one family and keep enough contrast |
-| Most diagrams end with a "Lumi's takeaway" band | One sentence tells the reader what to remember |
+| Most diagrams end with a "Takeaway" band | One sentence tells the reader what to remember |
| A `` that states everything the picture shows | Readers who cannot see the image get the same facts |
**Every label must be true.** Check each command, path, field, and error code in
@@ -179,55 +180,13 @@ other variants and sizes.
own file name so a failed capture is never confused with a corrected one, and
keep previews outside the repository.
-## Meet Lumi
+## Mascot artwork
-
-
-**Lumi** is Weave's firefly guide: a sculpted forest-green body, translucent
-mint wings, and an amber lantern. The image uses soft lighting and a transparent
-background so it sits naturally on white, mist, or dark forest surfaces.
-
-Use the [PNG master](../assets/lumi.png) for new artwork. Studio's pairing and
-home screens and the documentation introductions use this image. Existing
-self-contained SVG diagrams and CLI graph exports retain the earlier vector
-illustration; those are separate assets, not a vector version of this image.
-The official Weave symbol remains the application icon and primary product mark.
-
-### Reuse Lumi in a page, slide, or diagram
-
-1. Download the PNG master above. Its alpha channel provides transparency;
- there is no white rectangle to remove.
-2. Keep the square aspect ratio. Leave space around both antennae and the wings,
- and avoid circular crops that cut through them.
-3. Use a displayed width of 96–240 pixels for an introduction or diagram
- companion. At small toolbar sizes, use the Weave symbol instead: Lumi's face
- and wings need room to remain legible.
-4. Place Lumi beside a short takeaway, outside the boxes and arrows that explain
- the system. Do not use the character as a status, permission, or execution
- indicator.
-5. Keep a single shared PNG reference in web pages. For the documentation's SVG
- diagrams, place it next to the diagram in the surrounding Markdown instead
- of embedding a copy of the raster in every SVG. This keeps diagrams editable
- and lets the browser cache the image once.
-
-For a guide under `docs/guides/`, use this pattern:
-
-```markdown
-
-{ .lumi-guide }
-
-Lumi's takeaway: review the proposed change before applying it.
-
-## Follow the process
-
-
-```
-
-The [Lumi assistant guide](guides/lumi.md) shows the image alongside the
-explanation and review diagram. In Studio, `studio/public/assets/lumi.png` is
-an identical copy of the master; update both together when the artwork changes.
-Machine JSON output contains no branding. CLI help uses the official logo;
-progress animation settings are in the [CLI reference](reference/cli.md#local-platform-commands).
+Firefly Weave no longer uses a mascot. Studio, the documentation, the API
+explorer and exported graph drawings carry no character artwork, and diagrams
+end with a plain **Takeaway** band. Machine JSON output contains no branding.
+CLI help uses the official logo; progress animation settings are in the
+[CLI reference](reference/cli.md#local-platform-commands).
## Next steps
diff --git a/docs/visual-guide.md b/docs/visual-guide.md
index ccc808b1..a00eba57 100644
--- a/docs/visual-guide.md
+++ b/docs/visual-guide.md
@@ -79,7 +79,7 @@ platform behaves; a generated workflow graph explains your own definition.
platform, or an external system. Credentials appear only on the side that
needs them.
- **A shaded note** gives a rule or a boundary that applies to the whole picture.
- **Lumi's takeaway**, at the bottom, is the one idea to remember.
+ **Takeaway**, at the bottom, is the one idea to remember.
- **Database diagrams** show selected relationships, not a complete schema or
permission to delete referenced records.
diff --git a/scripts/brand/weave-lockup.mjs b/scripts/brand/weave-lockup.mjs
new file mode 100644
index 00000000..4dde88ba
--- /dev/null
+++ b/scripts/brand/weave-lockup.mjs
@@ -0,0 +1,140 @@
+/*
+Copyright 2026 Firefly Software Foundation.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+Author: Firefly Software Foundation
+SPDX-License-Identifier: Apache-2.0
+*/
+// Composes the Firefly Weave lockup and the Firefly icon files from a local
+// checkout of the private Firefly Brand Kit. Run it from the kit's root (the
+// kit resolves its fonts relative to the working directory):
+//
+// node /scripts/brand/weave-lockup.mjs --out
+//
+// No kit code is copied into Weave: the kit's logo and type modules are
+// imported from the working directory at run time, and only the generated
+// files are written. The construction is the approved one in the brand
+// foundation design: the compact Firefly logo, a hairline separator 1X after
+// the chevron apex, and "Weave" in Manrope 500 1X after the separator, where
+// 1X is the Firefly x-height (108 drawing units).
+import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+import { parseArgs } from "node:util";
+
+const { values } = parseArgs({
+ options: {
+ out: { type: "string" },
+ favicons: { type: "string", default: "dist/Firefly-Brand-Kit/02-Logo/favicon" },
+ },
+});
+if (!values.out || !existsSync(join(values.out, "studio/package.json")))
+ throw Error("Pass --out .");
+if (!existsSync("scripts/logo.mjs"))
+ throw Error("Run this from the root of a Firefly Brand Kit checkout: ./scripts/logo.mjs is missing.");
+
+const kit = (path) => import(pathToFileURL(resolve(path)).href);
+const { geometry, placed, svg, icon } = await kit("scripts/logo.mjs");
+const { face, line } = await kit("scripts/lib/type.mjs");
+
+const X = 108;
+const PAPER = "#f3f1eb";
+const CHARCOAL = "#10110f";
+const r = (v) => Math.round(v * 100) / 100;
+const NOTICE =
+ "\n";
+
+/** Adds role, title and desc to a drawing's root without touching the drawing. */
+function accessible(drawing, title, desc) {
+ const open = drawing.indexOf(">", drawing.indexOf("${title}${desc}${drawing.slice(open)}\n`;
+}
+
+/** The lockup in drawing units, with its measurements. */
+function lockup({ ink, solid }) {
+ const [x0, top, FW, FH] = geometry().compact;
+ const logo = svg({ ink, slogan: false, solid, title: "", id: "weave-lockup-trail" });
+ const sx = x0 + FW - 1 + X;
+ const half = (1.25 * FH) / 2;
+ const [y0, y1] = [-54 - half, -54 + half];
+ const separator = ``;
+ const font = face(500);
+ const size = font.unitsPerEm * 0.1;
+ const tracking = -45 / font.unitsPerEm;
+ const scale = size / font.unitsPerEm;
+ const run = font.layout("Weave");
+ const first = run.glyphs[0].bbox.minX * scale;
+ const x = sx + X - first;
+ let cursor = x;
+ run.positions.slice(0, -1).forEach((p) => (cursor += p.xAdvance * scale + tracking * size));
+ const right = cursor + run.positions.at(-1).xOffset * scale + run.glyphs.at(-1).bbox.maxX * scale;
+ const word = line("Weave", { x, y: 0, size, weight: 500, tracking, fill: ink });
+ const box = [x0, r(y0 - 1), r(right + 1 - x0), r(y1 - y0 + 2)];
+ const drawing = `${placed(logo, x0, top, FW)}${separator}${word}`;
+ return { drawing, FW, width: box[2], height: box[3] };
+}
+
+const colorway = (ink) => (ink === PAPER ? "paper" : "charcoal");
+const files = {};
+for (const [name, ink, solid] of [
+ ["weave-lockup-reversed", PAPER, false],
+ ["weave-lockup-color", CHARCOAL, false],
+ ["weave-lockup-small-reversed", PAPER, true],
+]) {
+ const { drawing, FW, width, height } = lockup({ ink, solid });
+ files[`assets/brand/${name}.svg`] = accessible(
+ drawing,
+ "Firefly Weave",
+ `The Firefly logo${solid ? " with a solid chevron" : ""}, a hairline separator and the word Weave, in ${colorway(ink)} on a transparent background.`,
+ );
+ console.log(
+ `${name}: FW ${r(FW)}, width ${width}, height ${height}, FW/width ${(FW / width).toFixed(4)}, Firefly part at 200px ${r((FW / width) * 200)}px`,
+ );
+}
+const tile = (options, id) => icon(options).replaceAll("icon-trail", id);
+files["assets/brand/firefly-icon.svg"] = accessible(
+ tile({}, "firefly-icon-trail"),
+ "Firefly",
+ "The Firefly icon: the amber spark and chevron with its light trail on a charcoal rounded tile.",
+);
+files["assets/brand/firefly-icon-small.svg"] = accessible(
+ tile({ solid: true }, "firefly-icon-small-trail"),
+ "Firefly",
+ "The Firefly icon for small sizes: the amber spark and a solid chevron on a charcoal rounded tile.",
+);
+files["assets/brand/firefly-mark.svg"] = accessible(
+ tile({ background: "none" }, "firefly-mark-trail"),
+ "Firefly",
+ "The Firefly mark: the amber spark and chevron with its light trail on a transparent background.",
+);
+for (const name of ["weave-lockup-reversed", "weave-lockup-small-reversed", "firefly-mark"])
+ files[`studio/public/assets/${name}.svg`] = files[`assets/brand/${name}.svg`];
+files["studio/public/favicon.svg"] = files["assets/brand/firefly-icon.svg"];
+
+for (const [path, text] of Object.entries(files)) {
+ const target = join(values.out, path);
+ mkdirSync(dirname(target), { recursive: true });
+ writeFileSync(target, text);
+}
+for (const name of ["favicon.ico", "apple-touch-icon.png"]) {
+ const source = join(values.favicons, name);
+ if (!existsSync(source))
+ throw Error(`Missing ${source}: run "npm run build -- --only logo" in the kit first.`);
+ copyFileSync(source, join(values.out, "studio/public", name));
+}
+console.log(`Wrote ${Object.keys(files).length + 2} files under ${resolve(values.out)}.`);
diff --git a/scripts/docs_hook.py b/scripts/docs_hook.py
index a35a429d..7808a039 100644
--- a/scripts/docs_hook.py
+++ b/scripts/docs_hook.py
@@ -45,8 +45,6 @@
"tests/fixtures/e2-provider/README.md": "project/provider-fixture.md",
}
ASSETS = (
- "assets/lumi.png",
- "assets/lumi.svg",
"assets/weave-logo.svg",
"assets/weave-logo-reversed.svg",
"assets/weave-logo-mono.svg",
diff --git a/src/firefly_weave/api/documentation.py b/src/firefly_weave/api/documentation.py
index 5032aef2..0a2215c2 100644
--- a/src/firefly_weave/api/documentation.py
+++ b/src/firefly_weave/api/documentation.py
@@ -23,7 +23,6 @@
from starlette.responses import HTMLResponse, JSONResponse
from starlette.routing import Route
-from firefly_weave.branding import LUMI_SVG_BODY
from firefly_weave.contracts.openapi import export_openapi
# Pin bytes as well as version: the browser rejects changed CDN assets.
@@ -66,13 +65,10 @@ async def swagger(request: Request) -> HTMLResponse:
-
-{LUMI_SVG_BODY}
"
- f''
- f"{LUMI_SVG_BODY}"
+ "external systems contacted"
)
return "\n".join(parts) + "\n"
diff --git a/studio/package-lock.json b/studio/package-lock.json
index 0f95843f..0273307c 100644
--- a/studio/package-lock.json
+++ b/studio/package-lock.json
@@ -28,6 +28,7 @@
"@angular/compiler-cli": "22.2.1",
"@playwright/test": "1.55.1",
"@types/node": "24.3.0",
+ "lucide-static": "1.52.0",
"prettier": "3.6.2",
"typescript": "6.0.2",
"vitest": "4.0.8"
@@ -5033,6 +5034,13 @@
"node": "20 || >=22"
}
},
+ "node_modules/lucide-static": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.52.0.tgz",
+ "integrity": "sha512-0zZYihAze4ZmUCcyNa9OkWYqn/Y5f9AdTh02AZIxMtP5ravcTTfoSIAnUPBH6ZvKxt+ZW28JB0ldzLRyD82fcw==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/magic-string": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz",
diff --git a/studio/package.json b/studio/package.json
index ad1e5d3f..2174b840 100644
--- a/studio/package.json
+++ b/studio/package.json
@@ -9,7 +9,8 @@
"check": "tsc --noEmit -p tsconfig.json",
"test": "vitest run",
"test:browser": "playwright test",
- "format:check": "prettier --check \"src/**/*.{ts,html,css}\" \"tests/**/*.ts\" \"*.json\" \"*config.ts\""
+ "icons": "node scripts/build-icons.mjs",
+ "format:check": "prettier --check \"src/**/*.{ts,html,css}\" \"tests/**/*.ts\" \"scripts/**/*.mjs\" \"*.json\" \"*config.ts\""
},
"dependencies": {
"@angular/common": "22.2.1",
@@ -31,6 +32,7 @@
"@angular/compiler-cli": "22.2.1",
"@playwright/test": "1.55.1",
"@types/node": "24.3.0",
+ "lucide-static": "1.52.0",
"prettier": "3.6.2",
"typescript": "6.0.2",
"vitest": "4.0.8"
diff --git a/studio/public/apple-touch-icon.png b/studio/public/apple-touch-icon.png
new file mode 100644
index 00000000..f1778d60
Binary files /dev/null and b/studio/public/apple-touch-icon.png differ
diff --git a/studio/public/assets/firefly-mark.svg b/studio/public/assets/firefly-mark.svg
new file mode 100644
index 00000000..6d4d18b9
--- /dev/null
+++ b/studio/public/assets/firefly-mark.svg
@@ -0,0 +1,2 @@
+
+FireflyThe Firefly mark: the amber spark and chevron with its light trail on a transparent background.
diff --git a/studio/public/assets/lumi.png b/studio/public/assets/lumi.png
deleted file mode 100644
index 0f6de218..00000000
Binary files a/studio/public/assets/lumi.png and /dev/null differ
diff --git a/studio/public/assets/lumi.svg b/studio/public/assets/lumi.svg
deleted file mode 100644
index 8319101b..00000000
--- a/studio/public/assets/lumi.svg
+++ /dev/null
@@ -1,67 +0,0 @@
-
-
-Lumi, the Firefly Weave guide
-A forest-green firefly with a softly sculpted face, attentive eyes, folded mint wings, and a warm golden lantern. The angular wings echo the woven Weave symbol.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/studio/public/assets/weave-lockup-reversed.svg b/studio/public/assets/weave-lockup-reversed.svg
new file mode 100644
index 00000000..58ba0519
--- /dev/null
+++ b/studio/public/assets/weave-lockup-reversed.svg
@@ -0,0 +1,2 @@
+
+Firefly WeaveThe Firefly logo, a hairline separator and the word Weave, in paper on a transparent background.
diff --git a/studio/public/assets/weave-lockup-small-reversed.svg b/studio/public/assets/weave-lockup-small-reversed.svg
new file mode 100644
index 00000000..7816e840
--- /dev/null
+++ b/studio/public/assets/weave-lockup-small-reversed.svg
@@ -0,0 +1,2 @@
+
+Firefly WeaveThe Firefly logo with a solid chevron, a hairline separator and the word Weave, in paper on a transparent background.
diff --git a/studio/public/assets/weave-logo-reversed.svg b/studio/public/assets/weave-logo-reversed.svg
deleted file mode 100644
index bc3c7b09..00000000
--- a/studio/public/assets/weave-logo-reversed.svg
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-Firefly Weave reversed symbol
-The woven W in white with a transparent diagonal underpass. For dark backgrounds.
-
-
-
-
-
-
-
-
-
-
-
diff --git a/studio/public/assets/weave-logo.svg b/studio/public/assets/weave-logo.svg
deleted file mode 100644
index ca557374..00000000
--- a/studio/public/assets/weave-logo.svg
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-Firefly Weave symbol
-Two interlaced V-shaped bands form a compact W. A clear diagonal underpass preserves the woven structure in color and in one color. Forest and jade on a transparent background; use on white or light neutral surfaces.
-
-
-
-
-
-
-
-
-
-
-
diff --git a/studio/public/favicon.ico b/studio/public/favicon.ico
new file mode 100644
index 00000000..2615ce3d
Binary files /dev/null and b/studio/public/favicon.ico differ
diff --git a/studio/public/favicon.svg b/studio/public/favicon.svg
new file mode 100644
index 00000000..4c52c208
--- /dev/null
+++ b/studio/public/favicon.svg
@@ -0,0 +1,2 @@
+
+FireflyThe Firefly icon: the amber spark and chevron with its light trail on a charcoal rounded tile.
diff --git a/studio/public/fonts/manrope/manrope-latin-ext-wght-normal.woff2 b/studio/public/fonts/manrope/manrope-latin-ext-wght-normal.woff2
new file mode 100644
index 00000000..bd24140a
Binary files /dev/null and b/studio/public/fonts/manrope/manrope-latin-ext-wght-normal.woff2 differ
diff --git a/studio/public/fonts/manrope/manrope-latin-wght-normal.woff2 b/studio/public/fonts/manrope/manrope-latin-wght-normal.woff2
new file mode 100644
index 00000000..71eb731d
Binary files /dev/null and b/studio/public/fonts/manrope/manrope-latin-wght-normal.woff2 differ
diff --git a/studio/public/licenses/NOTICE.txt b/studio/public/licenses/NOTICE.txt
new file mode 100644
index 00000000..293d90ca
--- /dev/null
+++ b/studio/public/licenses/NOTICE.txt
@@ -0,0 +1,35 @@
+Firefly Weave
+Copyright 2026 Firefly Software Foundation.
+Author: Firefly Software Foundation
+SPDX-License-Identifier: Apache-2.0
+
+This product includes software developed for Firefly Weave by the Firefly
+Software Foundation, licensed under the Apache License 2.0.
+
+Firefly marks. The Firefly name, the firefly wordmark with its spark and
+chevron, the Firefly icon and the Firefly Weave logo that combines them with
+the word "Weave" are trademarks and proprietary artwork of Firefly Software
+Solutions Inc. Copyright 2026 Firefly Software Solutions Inc. All rights
+reserved. The Firefly Software Foundation uses them in Firefly Weave with the
+permission of Firefly Software Solutions Inc. They are not licensed under the
+Apache License 2.0, and this notice grants no right to use, modify or
+redistribute them apart from unmodified copies of this product. The source
+inventory, docs/contributing/source-inventory.toml, lists every file that
+carries them.
+
+Manrope. Studio includes the Manrope typeface, Copyright 2019 The Manrope
+Project Authors (https://github.com/sharanda/manrope), licensed under the SIL
+Open Font License, Version 1.1 (assets/fonts/manrope/OFL.txt).
+
+Lucide. Studio's icons are derived from Lucide (https://lucide.dev),
+lucide-static 1.52.0, Copyright (c) 2026 Lucide Icons and Contributors,
+licensed under the ISC License; portions derive from Feather, Copyright (c)
+2013-present Cole Bemis, licensed under the MIT License
+(studio/public/licenses/lucide-LICENSE.txt).
+
+Firefly Weave depends on separately distributed software, including PyFly.
+Those dependencies retain their own copyrights, licenses, and notices. This
+NOTICE does not reattribute third-party code or replace dependency license terms.
+
+Apache License 2.0 grants no permission to use trade names or trademarks except
+as described in the license. See LICENSE for the complete terms.
diff --git a/studio/public/licenses/lucide-LICENSE.txt b/studio/public/licenses/lucide-LICENSE.txt
new file mode 100644
index 00000000..718bb3f0
--- /dev/null
+++ b/studio/public/licenses/lucide-LICENSE.txt
@@ -0,0 +1,43 @@
+ISC License
+
+Copyright (c) 2026 Lucide Icons and Contributors
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+---
+
+The following Lucide icons are derived from the Feather project:
+
+airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
+
+The MIT License (MIT) (for the icons listed above)
+
+Copyright (c) 2013-present Cole Bemis
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/studio/public/licenses/manrope-OFL.txt b/studio/public/licenses/manrope-OFL.txt
new file mode 100644
index 00000000..462280f3
--- /dev/null
+++ b/studio/public/licenses/manrope-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2019 The Manrope Project Authors (https://github.com/sharanda/manrope)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/studio/scripts/build-icons.mjs b/studio/scripts/build-icons.mjs
new file mode 100644
index 00000000..b96e91d7
--- /dev/null
+++ b/studio/scripts/build-icons.mjs
@@ -0,0 +1,227 @@
+/*
+Copyright 2026 Firefly Software Foundation.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+Author: Firefly Software Foundation
+SPDX-License-Identifier: Apache-2.0
+*/
+// Writes src/app/icon-data.ts from lucide-static, Studio's only icon source.
+// A new icon is a new entry in iconMap, never an inline SVG: run
+// `npm run icons` and commit both files. icon.test.ts regenerates the data
+// and fails when the committed file differs.
+import { readFileSync, writeFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import * as prettier from "prettier";
+
+export const LUCIDE_VERSION = "1.52.0";
+
+/** Weave icon name → Lucide icon name (one Lucide icon per Weave name). */
+export const iconMap = {
+ // Navigation and objects
+ home: "house",
+ workflows: "workflow",
+ runs: "circle-play",
+ tasks: "clipboard-check",
+ email: "mail",
+ connections: "plug",
+ operations: "network",
+ workers: "cpu",
+ settings: "settings",
+ workspace: "layers",
+ user: "user-round",
+ template: "layout-template",
+ assistant: "messages-square",
+ // Step kinds (brand mapping; lanes do not reassign them)
+ action: "zap",
+ decisionTable: "table",
+ llm: "sparkles",
+ transform: "braces",
+ switch: "split",
+ parallel: "git-fork",
+ wait: "hourglass",
+ signal: "radio-tower",
+ humanTask: "user-round-check",
+ fail: "octagon-x",
+ // Commands
+ play: "play",
+ plus: "plus",
+ search: "search",
+ chevron: "chevron-right",
+ undo: "undo-2",
+ redo: "redo-2",
+ fit: "scan",
+ source: "code-xml",
+ check: "check",
+ download: "download",
+ upload: "upload",
+ trash: "trash",
+ menu: "menu",
+ more: "ellipsis",
+ close: "x",
+ save: "save",
+ lock: "lock",
+ external: "external-link",
+ copy: "copy",
+ refresh: "refresh-cw",
+ warning: "triangle-alert",
+ help: "circle-question-mark",
+ logout: "log-out",
+ cloud: "cloud",
+ laptop: "laptop",
+ panel: "panel-right",
+ tidy: "layout-grid",
+ failCircle: "circle-x",
+ // Triggers (Lane E)
+ manualTrigger: "mouse-pointer-click",
+ webhook: "webhook",
+ schedule: "calendar-clock",
+ kafka: "inbox",
+ appEvent: "app-window",
+ calledTrigger: "square-arrow-down-right",
+ // AI agent slots (Lane A)
+ agent: "bot",
+ model: "brain-circuit",
+ tools: "wrench",
+ memory: "database",
+ output: "file-output",
+ // Flow kinds (Lane L and Lane E)
+ forEach: "iteration-cw",
+ callWorkflow: "square-stack",
+ textTemplate: "text-cursor-input",
+ // Editor (Lane E)
+ execute: "flask-conical",
+ pin: "pin",
+ unpin: "pin-off",
+ stale: "clock-alert",
+ breakpoint: "circle-dot",
+ runHistory: "history",
+ stickyNote: "sticky-note",
+ minimap: "map",
+ zoomIn: "zoom-in",
+ zoomOut: "zoom-out",
+ drag: "grip-vertical",
+ spinner: "loader-circle",
+ // Operate (Lane O)
+ overview: "gauge",
+ clusters: "server",
+ metrics: "chart-line",
+ logs: "scroll-text",
+ alerts: "bell",
+ alertFiring: "bell-ring",
+ incidents: "siren",
+ approvals: "stamp",
+ compose: "container",
+ kubernetes: "ship-wheel",
+ drain: "circle-pause",
+ revoke: "ban",
+ // Severity and health
+ critical: "octagon-alert",
+ info: "info",
+ degraded: "circle-alert",
+ ok: "circle-check",
+ off: "circle-off",
+};
+
+/** The attributes weave-icon binds for each element Lucide uses. */
+const ATTRIBUTES = {
+ path: ["d"],
+ circle: ["cx", "cy", "r"],
+ rect: ["x", "y", "width", "height", "rx", "ry"],
+ line: ["x1", "y1", "x2", "y2"],
+ polyline: ["points"],
+ polygon: ["points"],
+ ellipse: ["cx", "cy", "rx", "ry"],
+};
+const studio = new URL("../", import.meta.url);
+const lucide = new URL("node_modules/lucide-static/", studio);
+const target = new URL("src/app/icon-data.ts", studio);
+const ELEMENT = /<(\w+)\b([^>]*?)\/>/g;
+
+/** The drawing of one Lucide icon as a list of elements and attributes. */
+export function lucideNodes(name) {
+ const file = new URL(`icons/${name}.svg`, lucide);
+ let text;
+ try {
+ text = readFileSync(file, "utf8");
+ } catch {
+ throw Error(
+ `lucide-static has no icon "${name}" (${fileURLToPath(file)}).`,
+ );
+ }
+ const start = text.indexOf(">", text.indexOf(""));
+ if (body.replace(ELEMENT, "").trim())
+ throw Error(`${name}.svg has content weave-icon cannot draw.`);
+ const nodes = [...body.matchAll(ELEMENT)].map(([, tag, rest]) => {
+ const allowed = ATTRIBUTES[tag];
+ if (!allowed)
+ throw Error(`${name}.svg uses <${tag}>, which weave-icon does not draw.`);
+ const attrs = {};
+ for (const [, key, value] of rest.matchAll(/([\w-]+)="([^"]*)"/g)) {
+ if (!allowed.includes(key))
+ throw Error(
+ `${name}.svg: <${tag}> has an unexpected "${key}" attribute.`,
+ );
+ attrs[key] = value;
+ }
+ return { tag, attrs };
+ });
+ if (!nodes.length) throw Error(`${name}.svg draws nothing.`);
+ return nodes;
+}
+
+/** The text of src/app/icon-data.ts, formatted as the repository formats it. */
+export async function renderIconData() {
+ const { version } = JSON.parse(
+ readFileSync(new URL("package.json", lucide), "utf8"),
+ );
+ if (version !== LUCIDE_VERSION)
+ throw Error(
+ `Expected lucide-static ${LUCIDE_VERSION}, found ${version}. Run npm ci.`,
+ );
+ const drawings = Object.fromEntries(
+ Object.entries(iconMap).map(([weave, name]) => [weave, lucideNodes(name)]),
+ );
+ const attributes = [...new Set(Object.values(ATTRIBUTES).flat())];
+ const source = `/*
+Generated by studio/scripts/build-icons.mjs from lucide-static ${LUCIDE_VERSION}
+(https://lucide.dev). Do not edit: change iconMap in the script and run
+\`npm run icons\`. Icon geometry: Copyright (c) 2026 Lucide Icons and
+Contributors, ISC License; icons derived from Feather: Copyright (c)
+2013-present Cole Bemis, MIT License. Full text:
+studio/public/licenses/lucide-LICENSE.txt.
+*/
+export type IconTag = ${Object.keys(ATTRIBUTES)
+ .map((t) => JSON.stringify(t))
+ .join(" | ")};
+export type IconAttribute = ${attributes.map((a) => JSON.stringify(a)).join(" | ")};
+export interface IconNode {
+ readonly tag: IconTag;
+ readonly attrs: Readonly>>;
+}
+/** Weave icon name → the Lucide icon it draws. */
+export const lucideNames = ${JSON.stringify(iconMap)} as const;
+export type IconName = keyof typeof lucideNames;
+/** Each icon's elements, in Lucide's order, on a 24 × 24 grid. */
+export const iconNodes: Readonly> = ${JSON.stringify(drawings)};
+`;
+ const options = await prettier.resolveConfig(fileURLToPath(target));
+ return prettier.format(source, { ...options, parser: "typescript" });
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ writeFileSync(target, await renderIconData());
+ console.log(
+ `Wrote ${fileURLToPath(target)} (${Object.keys(iconMap).length} icons).`,
+ );
+}
diff --git a/studio/src/app/app.html b/studio/src/app/app.html
index 0b44a92e..1b0adaf0 100644
--- a/studio/src/app/app.html
+++ b/studio/src/app/app.html
@@ -18,9 +18,7 @@
@if (connecting) {
- Workflow profiles do not change Lumi. A platform operator must install
- and authorize the Agentic worker before these tasks can run.
+ Workflow profiles do not change Weave AI. A platform operator must
+ install and authorize the Agentic worker before these tasks can run.
The configuration was validated. Provider access has not been tested. No
model request was sent. A platform operator must authorize the worker
- release to use this connection before a workflow can run; Lumi uses its
- separately configured gateway.
+ release to use this connection before a workflow can run; Weave AI uses
+ its separately configured gateway.
Done
} @else {
@@ -90,7 +90,8 @@ import {
Choose a provider
Name this connection so authors can recognize it. Choose models or
- Azure deployments later in workflow AI profiles or Lumi settings.
+ Azure deployments later in workflow AI profiles or Weave AI
+ settings.
Override only for an approved proxy or compatible service. The
- worker or Lumi gateway must allow the exact endpoint.
+ worker or Weave AI gateway must allow the exact endpoint.
}
@@ -212,7 +213,7 @@ import {
The platform will validate the configuration. No model request or
connectivity test is sent. Your operator still needs to authorize
- the worker release or configure Lumi's gateway before use.
+ the worker release or configure Weave AI's gateway before use.
Only selected structured results are sent to this step's provider.
- Conversations from Lumi and other executions are never included.
+ Conversations from Weave AI and other executions are never included.
- Connect to a platform to ask Lumi. Your workflow can stay a local
- draft.
+ Connect to a platform to ask Weave AI. Your workflow can stay a
+ local draft.
} @else if (!host.can("lumi.use")) {
- Ask an administrator for access to Lumi in this environment.
+ Ask an administrator for access to Weave AI in this environment.
} @else if (!settings && !status?.configured && !loading) {
- Lumi is unavailable in this environment. An administrator configures
- its model and connection; a platform operator enables the Lumi
- gateway. Saving model settings alone does not deploy the gateway.
+ Weave AI is unavailable in this environment. An administrator
+ configures its model and connection; a platform operator enables the
+ Weave AI gateway. Saving model settings alone does not deploy the
+ gateway.
}
@if (loading) {
-
Loading Lumi…
+
Loading Weave AI…
}
@if (settings) {
- Configure Lumi for this environment. Workflow AI profiles have
- their own settings.
+ Choose Weave AI's model and connection for this environment.
+ Workflow AI profiles have their own settings.
@if (!host.can("connection.manage")) {
To select or save a provider connection, ask an administrator
- for connection.manage in this environment as well as Lumi
+ for connection.manage in this environment as well as Weave AI
manager.
- Choose Lumi's provider connection. Credentials stay with the
- platform operator.
+ Choose Weave AI's provider connection. Credentials stay with
+ the platform operator.
- Lumi pins this exact revision. Choose a connection with the
- same provider as the profile; changing a workflow profile
- will not change these settings.
+ Weave AI pins this exact revision. Choose a connection with
+ the same provider as the profile; changing a workflow
+ profile will not change these settings.
- Saving applies these settings to Lumi in the current
+ Saving applies these settings to Weave AI in the current
environment. It does not change workflow AI profiles or send
- a model request. The platform operator must enable the Lumi
- gateway and authorize this connection before you can ask
- Lumi.
+ a model request. The platform operator must enable the Weave
+ AI gateway and authorize this connection before you can ask
+ Weave AI.
- The local draft changed after this request. Ask Lumi again with
- the current source.
+ The local draft changed after this request. Ask Weave AI again
+ with the current source.
- Provider connections are shared environment resources. Lumi uses its
- own model settings; workflow AI profiles stay in each workflow.
+ Provider connections are shared environment resources. Weave AI uses
+ its own model settings; workflow AI profiles stay in each workflow.
- Ask an administrator for Lumi manager and connection.manage grants
- in this environment to configure Lumi and its provider connection.
+ Ask an administrator for Weave AI manager and connection.manage
+ grants in this environment to set up Weave AI and its provider
+ connection.
}
For a workflow model, add an AI task and open Configure workflow AI
- profiles. A platform operator installs the Agentic worker and Lumi
+ profiles. A platform operator installs the Agentic worker and Weave AI
gateway and provisions approved secret handles.
diff --git a/studio/src/app/task-form.ts b/studio/src/app/task-form.ts
index f56b38e9..26695ff5 100644
--- a/studio/src/app/task-form.ts
+++ b/studio/src/app/task-form.ts
@@ -736,8 +736,8 @@ let sequence = 0;
border-left: 1px solid var(--line);
}
.binding-mode[aria-pressed="true"] {
- background: var(--forest);
- color: var(--on-dark);
+ background: var(--accent);
+ color: var(--on-accent);
font-weight: 600;
}
.binding-help summary {
@@ -799,7 +799,7 @@ let sequence = 0;
border: 1px solid var(--line);
border-radius: 999px;
padding: 1px 8px;
- background: var(--mist);
+ background: var(--raised);
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
diff --git a/studio/src/app/templates/new-menu.ts b/studio/src/app/templates/new-menu.ts
index d1fe2d59..79b16ec3 100644
--- a/studio/src/app/templates/new-menu.ts
+++ b/studio/src/app/templates/new-menu.ts
@@ -107,7 +107,7 @@ let sequence = 0;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
- box-shadow: 0 8px 28px #173d3429;
+ box-shadow: var(--shadow-2);
padding: 4px;
}
.new-menu-list button {
@@ -123,11 +123,11 @@ let sequence = 0;
}
.new-menu-list button:hover,
.new-menu-list button:focus-visible {
- background: var(--mist);
+ background: var(--hover);
}
.new-menu-list weave-icon {
flex: none;
- color: var(--jade);
+ color: var(--muted);
}
.new-menu-list small {
display: block;
diff --git a/studio/src/app/templates/template-gallery.ts b/studio/src/app/templates/template-gallery.ts
index 5cbfc822..4cbfed5c 100644
--- a/studio/src/app/templates/template-gallery.ts
+++ b/studio/src/app/templates/template-gallery.ts
@@ -210,7 +210,7 @@ let sequence = 0;
align-items: center;
width: 120px;
height: 32px;
- color: var(--jade-strong);
+ color: var(--muted);
}
.glyph-line {
width: 12px;
diff --git a/studio/src/app/workspace-picker.ts b/studio/src/app/workspace-picker.ts
index e23b1785..ca854746 100644
--- a/studio/src/app/workspace-picker.ts
+++ b/studio/src/app/workspace-picker.ts
@@ -155,20 +155,20 @@ let sequence = 0;
background: var(--surface);
}
.environment-card:hover {
- border-color: #9cb9aa;
- background: var(--mist);
+ border-color: var(--border-hover);
+ background: var(--hover);
}
.environment-card.selected {
- border-color: var(--jade);
- background: #e8f3ed;
- box-shadow: inset 0 0 0 1px var(--jade);
+ border-color: var(--accent);
+ background: var(--selected);
+ box-shadow: inset 0 0 0 1px var(--accent);
}
.environment-card input {
width: 16px;
height: 16px;
margin: 0;
flex: none;
- accent-color: var(--forest);
+ accent-color: var(--accent);
}
.environment-card:has(input:focus-visible) {
outline: 2px solid var(--focus);
diff --git a/studio/src/index.html b/studio/src/index.html
index d35068c8..e528e30a 100644
--- a/studio/src/index.html
+++ b/studio/src/index.html
@@ -22,6 +22,18 @@
Firefly Weave Studio
+
+
+
+
+
+
diff --git a/studio/src/styles.css b/studio/src/styles.css
index 6f08fa69..dd392859 100644
--- a/studio/src/styles.css
+++ b/studio/src/styles.css
@@ -15,77 +15,131 @@ limitations under the License.
Author: Firefly Software Foundation
SPDX-License-Identifier: Apache-2.0
*/
-/* Design tokens. Ratios are WCAG 2.x contrast against the named surface. */
+/* Manrope (OFL-1.1), self-hosted: public/fonts/manrope, from
+ @fontsource-variable/manrope 5.3.0. Latin and Latin Extended only. */
+@font-face {
+ font-family: "Manrope";
+ font-style: normal;
+ font-display: swap;
+ font-weight: 200 800;
+ src: url("/fonts/manrope/manrope-latin-ext-wght-normal.woff2") format("woff2");
+ unicode-range:
+ U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
+ U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
+ U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+@font-face {
+ font-family: "Manrope";
+ font-style: normal;
+ font-display: swap;
+ font-weight: 200 800;
+ src: url("/fonts/manrope/manrope-latin-wght-normal.woff2") format("woff2");
+ unicode-range:
+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
+ U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
+ U+2215, U+FEFF, U+FFFD;
+}
+/* Design tokens (contract C1): the dark theme. Every token is named for its
+ role, never its color, and components use tokens only, so a light theme is
+ one more value set under :root[data-theme="light"] with color-scheme: light.
+ Ratios are WCAG 2.x contrast against the named surface. */
:root {
- color-scheme: light;
- /* brand */
- --forest: #173d34;
- --forest-hover: #244b3e;
- --forest-press: #10302a;
- --jade: #367d68;
- --jade-strong: #2c6a57;
- --mint: #a1d1b9;
- --mist: #eef4f0;
- --gold: #b88322;
- --gold-soft: #fbf4df;
- /* surfaces */
- --bg: #f5f8f5;
- --surface: #fff;
- --sunken: #f8faf8;
- --canvas: #fafcf9;
- --hover: #eef4f0;
- --selected: #e3efe8;
- /* the forest sidebar: active row, and quiet text (8.13 on forest) */
- --forest-active: #315d4b;
- --on-dark-muted: #c7d9d0;
+ color-scheme: dark;
+ /* surfaces, from darkest to lightest */
+ --bg: #10110f; /* app background, sidebar, header, page */
+ --sunken: #131412; /* inputs, wells, code blocks, table heads */
+ --surface: #1a1b17; /* panels, cards, inspector, dialogs */
+ --raised: #1f201c; /* menus, popovers, toasts, node cards, tinted blocks */
+ --hover: #272725;
+ --selected: #2c2416; /* selected rows and cards, current nav item */
+ --accent-soft: #2c2416; /* the live "now" panel, amber-tinted areas */
+ --disabled-bg: #20211e;
+ --canvas: #10110f;
+ --canvas-dot: #33342c; /* decorative, 1.50 on canvas */
+ --scrim: rgb(0 0 0 / 0.6); /* behind dialogs and side sheets */
+ /* text: 4.5:1 or more on every surface above */
+ --text: #f3f1eb; /* 16.76 bg, 13.25 hover */
+ --muted: #bfb8ab; /* 9.61 bg, 7.60 hover */
+ --subtle: #9d9c97; /* placeholders, timestamps, axis text: 5.44 hover */
+ --link: #facc8a; /* links, tertiary buttons: 10.03 hover */
+ --disabled-ink: #959590; /* 5.38 on --disabled-bg */
+ /* accent: primary fill, focus, selection, current indicator, live run */
+ --accent: #ffb34a; /* 10.64 bg, 9.21 raised */
+ --accent-hover: #fcc271;
+ --accent-press: #e69f3f;
+ --on-accent: #10110f; /* on accent and solid status fills: 7+ */
+ --accent-glow: rgb(255 179 74 / 0.25); /* decorative halo */
/* lines */
- --line: #dbe5df; /* dividers only */
- --border: #ccdcd2; /* cards, tables, secondary buttons */
- --border-hover: #9cb9aa;
- --field-border: #738c80; /* inputs: 3.63 white, 3.39 bg, 3.25 mist */
- --field-border-hover: #5f8a76;
- --flow-line: #5f8a76; /* canvas edges + arrowheads: 3.78 on --canvas */
- --node-border: #738c80;
- /* text */
- --text: #253e36; /* 11.5 white, 9.76 selected */
- --muted: #4d655d; /* 6.30 white, 5.33 selected */
- --link: #2c6a57; /* 6.35 white, 5.70 mist; jade is 4.39 on mist */
- --on-dark: #fff;
- --on-dark-accent: #a1d1b9; /* mint on forest 7.04 */
- --disabled-bg: #eef2ef;
- --disabled-ink: #5c706a; /* 4.67 */
- /* semantic: ink on bg ratio; every border is 1.3+ on --selected */
- --success-ink: #1d6047;
- --success-bg: #e2f1e9;
- --success-bd: #a9d3bd;
- --success: #2c6a57; /* 6.39 */
- --info-ink: #245a6b;
- --info-bg: #e3eff2;
- --info-bd: #b7d3dc;
- --info: #2f6f84; /* 6.51 */
- --warning-ink: #73500f;
- --warning-bg: #fbf0d6;
- --warning-bd: #e2cb96;
- --warning: #b88322; /* 6.43 */
- --danger-ink: #962531;
- --danger-bg: #fbe8e9;
- --danger-bd: #e6c2c5;
- --danger: #a12b35; /* 6.84; white on --danger 7.21 */
- --neutral-ink: #465d55;
- --neutral-bg: #edf1ee;
- --neutral-bd: #c3d0c9; /* 6.23 */
- /* the live thread */
- --live: #b88322;
- --live-glow: rgb(184 131 34 / 0.28); /* 3.23 on --canvas */
- /* focus */
- --focus: #2c6a57;
- --focus-on-dark: #a1d1b9;
+ --line: #2e2f2a; /* dividers, gridlines (decorative) */
+ --border: #474a42; /* cards, tables, overlays, secondary buttons */
+ --border-hover: #5b5f57;
+ --field-border: #767672; /* inputs: 3.28 on hover, 3:1 everywhere */
+ --field-border-hover: #8d8d86;
+ /* status: ink 6:1 on its own -bg; -bd is decorative */
+ --success-ink: #97c08c;
+ --success-bg: #262c23;
+ --success-bd: #4c5d46;
+ --success: #97c08c;
+ --info-ink: #9db7c6;
+ --info-bg: #272b28;
+ --info-bd: #4e595d;
+ --info: #9db7c6;
+ --warning-ink: #e8c85f;
+ --warning-bg: #2f2c1e;
+ --warning-bd: #6c6034;
+ --warning: #e8c85f;
+ --danger-ink: #f0938a;
+ --danger-bg: #2f2722;
+ --danger-bd: #704b45;
+ --danger: #f0938a;
+ --danger-hover: #f4a9a1;
+ --neutral-ink: #bfb8ab;
+ --neutral-bg: #272725;
+ --neutral-bd: #474a42;
+ /* focus and selection */
+ --focus: #ffb34a; /* 8.41 on hover, the lowest */
+ --selection: rgb(255 179 74 / 0.32);
+ /* canvas */
+ --flow-line: #767672; /* edges, arrowheads, handles: 4.15 on canvas */
+ --flow-line-dim: #62645b; /* edges a run did not take: 3.15 */
+ --node-bg: #1f201c;
+ --node-border: #767672;
+ --live: #ffb34a;
+ --live-glow: rgb(255 179 74 / 0.35);
+ --marquee-bg: rgb(255 179 74 / 0.08);
+ /* charts: categorical series in order, then "(other)" */
+ --series-1: #ffb34a;
+ --series-2: #6b8795;
+ --series-3: #b4bdb8;
+ --series-4: #7a8460;
+ --series-other: #767672;
+ /* sticky notes: text is --text, borders are decorative */
+ --note-yellow-bg: #373321;
+ --note-yellow-bd: #6c6034;
+ --note-blue-bg: #2c3130;
+ --note-blue-bd: #4e595d;
+ --note-green-bg: #2c3227;
+ --note-green-bd: #4c5d46;
+ --note-pink-bg: #382c27;
+ --note-pink-bd: #704b45;
+ --note-purple-bg: #302e31;
+ --note-purple-bd: #5a5261;
+ --note-gray-bg: #31312c;
+ --note-gray-bd: #5c5a52;
+ /* code editor, on --sunken */
+ --syntax-keyword: #b4bdb8;
+ --syntax-string: #b0b59f;
+ --syntax-number: #a8b7bc;
+ /* diffs: lines also carry + or − */
+ --diff-add-bg: #262c23;
+ --diff-add-ink: #97c08c;
+ --diff-del-bg: #2f2722;
+ --diff-del-ink: #f0938a;
+ /* loading */
+ --skeleton: #272725;
/* type */
- /* Avenir Next on macOS and Segoe UI on Windows; elsewhere a grotesque with
- similar widths before system-ui, which on Linux is often much wider. */
--font-sans:
- "Avenir Next", "Segoe UI", "Helvetica Neue", Helvetica, Arial,
- "Liberation Sans", system-ui, sans-serif;
+ "Manrope", system-ui, -apple-system, "Segoe UI", Arial, sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--type-caption: 500 12px/16px var(--font-sans); /* pills, table heads, meta */
--type-small: 400 13px/20px var(--font-sans); /* help, dense cells */
@@ -94,8 +148,8 @@ SPDX-License-Identifier: Apache-2.0
--type-button: 600 14px/20px var(--font-sans);
--type-title-sm: 600 16px/24px var(--font-sans); /* panel, dialog */
--type-title-md: 600 20px/28px var(--font-sans); /* section, designer name */
- --type-title-lg: 700 24px/32px var(--font-sans); /* page title */
- --type-display: 700 32px/40px var(--font-sans); /* Home welcome, pairing */
+ --type-title-lg: 600 24px/32px var(--font-sans); /* page title */
+ --type-display: 500 32px/40px var(--font-sans); /* Home welcome, pairing */
--type-mono: 400 12px/18px var(--font-mono);
/* space (4-grid) */
--space-1: 4px;
@@ -109,29 +163,36 @@ SPDX-License-Identifier: Apache-2.0
--space-12: 48px;
/* radius */
--radius-xs: 4px; /* tags, checkboxes, inline code */
- --radius-sm: 6px; /* buttons, inputs, menu items */
- --radius-md: 8px; /* cards, tables, panels, nodes, toasts */
+ --radius-sm: 6px; /* buttons, inputs, menu items, nodes */
+ --radius-md: 8px; /* cards, tables, panels, toasts */
--radius-lg: 12px; /* dialogs, popovers, menus */
--radius-pill: 999px;
- /* elevation */
- --shadow-1: 0 1px 2px rgb(23 61 52 / 0.06); /* canvas nodes and tools */
- --shadow-2: 0 8px 24px rgb(23 61 52 / 0.14); /* menus, popovers, toasts */
- --shadow-3: 0 24px 64px rgb(23 61 52 / 0.22); /* modals */
- --scrim: rgb(23 61 52 / 0.32); /* behind a modal side sheet */
+ /* elevation: overlays also draw 1px --border */
+ --shadow-1: 0 1px 2px rgb(0 0 0 / 0.4); /* canvas nodes and tools */
+ --shadow-2: 0 8px 24px rgb(0 0 0 / 0.5); /* menus, popovers, toasts */
+ --shadow-3: 0 24px 64px rgb(0 0 0 / 0.6); /* dialogs, side sheets */
/* controls */
--control-sm: 32px;
--control-md: 36px;
--control-touch: 44px;
- /* motion */
- --ease: cubic-bezier(0.2, 0, 0, 1);
- --dur-fast: 120ms;
- --dur: 200ms;
+ /* motion (brand motion tokens) */
+ --ease: cubic-bezier(0.22, 1, 0.36, 1); /* enter, the default */
+ --ease-move: cubic-bezier(0.65, 0, 0.35, 1);
+ --ease-exit: cubic-bezier(0.55, 0, 1, 0.45);
+ --dur-fast: 150ms; /* hover, focus */
+ --dur: 200ms; /* small state changes, toasts */
+ --dur-panel: 300ms; /* panels, menus, sheets */
+ --dur-expressive: 1200ms; /* the live pulse on real runs */
/* layout */
--panel-w: clamp(360px, 25vw, 480px);
font: var(--type-body);
color: var(--text);
background: var(--bg);
}
+::selection {
+ background: var(--selection);
+ color: var(--text);
+}
* {
box-sizing: border-box;
}
@@ -172,17 +233,17 @@ button:hover,
}
button.primary,
.button.primary {
- background: var(--forest);
- border-color: var(--forest);
- color: var(--on-dark);
+ background: var(--accent);
+ border-color: var(--accent);
+ color: var(--on-accent);
}
button.primary:hover,
.button.primary:hover {
- background: var(--forest-hover);
- border-color: var(--forest-hover);
+ background: var(--accent-hover);
+ border-color: var(--accent-hover);
}
button.primary:active {
- background: var(--forest-press);
+ background: var(--accent-press);
}
button.tertiary {
border-color: transparent;
@@ -206,12 +267,12 @@ button.danger.solid,
button.danger-solid {
background: var(--danger);
border-color: var(--danger);
- color: var(--on-dark);
+ color: var(--on-accent);
}
button.danger.solid:hover,
button.danger-solid:hover {
- background: var(--danger-ink);
- border-color: var(--danger-ink);
+ background: var(--danger-hover);
+ border-color: var(--danger-hover);
}
button.sm {
min-height: var(--control-sm);
@@ -228,7 +289,7 @@ button.icon-button:hover {
background: var(--hover);
}
/* Disabled is a flat token, never opacity: a disabled primary must not read
- as pressed, and its label stays legible (4.67:1). */
+ as pressed, and its label stays legible (5.38:1 on --disabled-bg). */
:is(button, .button):is(:disabled, [aria-disabled="true"]),
:is(button, .button):is(:disabled, [aria-disabled="true"]):hover {
opacity: 1;
@@ -283,9 +344,6 @@ button.icon-button:hover {
outline-offset: 1px;
border-color: var(--focus);
}
-:is(.sidebar, .toast, .forest-surface) :focus-visible {
- outline-color: var(--focus-on-dark);
-}
/* A heading or region that receives focus only to move the reading position. */
:is(h1, h2, h3, main, section, aside)[tabindex="-1"]:focus {
outline: none;
@@ -308,7 +366,13 @@ textarea {
input,
select,
textarea {
- accent-color: var(--jade);
+ accent-color: var(--accent);
+}
+/* Chromium draws a placeholder in #757575 (3.55:1 on --raised); --subtle is
+ 5.44:1 on --hover, the lowest field surface. */
+::placeholder {
+ color: var(--subtle);
+ opacity: 1;
}
input:where(
:not([type="checkbox"], [type="radio"], [type="range"], [type="file"])
@@ -447,8 +511,8 @@ pre {
overflow: hidden;
}
.sidebar {
- background: var(--forest);
- color: var(--on-dark);
+ background: var(--bg);
+ color: var(--text);
display: flex;
flex-direction: column;
padding: 20px 12px 16px;
@@ -460,22 +524,22 @@ pre {
.brand {
display: flex;
align-items: center;
- gap: 12px;
- padding: 0 6px 24px;
- color: var(--on-dark);
+ padding: 0 0 24px;
+ color: var(--text);
text-decoration: none;
- font: 700 16px/20px var(--font-sans);
}
-.brand img {
+/* The lockup fills the sidebar's 200px content width; wherever the sidebar is
+ narrow, CSS shows the 32px Firefly mark in a 44px box instead. */
+.brand-lockup {
+ display: block;
+ width: 200px;
+ height: auto;
+}
+.brand-mark {
+ display: none;
width: 44px;
height: 44px;
-}
-.brand small {
- display: block;
- color: var(--mint);
- font-size: 12px;
- font-weight: 400;
- margin-top: 3px;
+ padding: 6px;
}
/* The skip link: the first stop on every page, shown only while focused. */
.skip-link {
@@ -485,8 +549,8 @@ pre {
z-index: 60;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-pill);
- background: var(--forest);
- color: var(--on-dark);
+ background: var(--accent);
+ color: var(--on-accent);
font: var(--type-label);
text-decoration: none;
transform: translateY(-200%);
@@ -494,7 +558,7 @@ pre {
.skip-link:focus,
.skip-link:focus-visible {
transform: none;
- outline: 2px solid var(--focus-on-dark);
+ outline: 2px solid var(--focus);
outline-offset: 2px;
}
.sidebar-nav {
@@ -510,7 +574,7 @@ pre {
}
.nav-group {
font: var(--type-caption);
- color: var(--on-dark-accent);
+ color: var(--muted);
margin: 16px 16px 6px;
}
.sidebar nav button {
@@ -519,7 +583,7 @@ pre {
justify-content: flex-start;
gap: 14px;
border: 0;
- color: var(--on-dark-muted);
+ color: var(--muted);
background: transparent;
height: 44px;
padding: 10px 16px;
@@ -528,11 +592,12 @@ pre {
font-weight: 500;
}
.sidebar nav button:hover {
- background: var(--forest-hover);
+ background: var(--hover);
+ color: var(--text);
}
.sidebar nav button.active {
- background: var(--forest-active);
- color: var(--on-dark);
+ background: var(--selected);
+ color: var(--text);
font-weight: 600;
}
.sidebar nav button.active:before {
@@ -541,7 +606,7 @@ pre {
left: 0;
top: 10px;
bottom: 10px;
- background: var(--mint);
+ background: var(--accent);
width: 3px;
border-radius: 2px;
}
@@ -554,14 +619,14 @@ pre {
}
.collapse-button {
background: transparent !important;
- color: var(--on-dark-muted);
+ color: var(--muted);
border: 0;
font-size: 12px;
width: 100%;
justify-content: flex-start;
}
.foundation {
- color: var(--on-dark-muted);
+ color: var(--muted);
display: block;
font-size: 12px;
padding: 8px 14px;
@@ -626,10 +691,10 @@ pre {
width: 8px;
height: 8px;
border-radius: 50%;
- background: var(--jade);
+ background: var(--success);
}
.connection-dot.offline {
- background: var(--gold);
+ background: var(--warning);
}
.avatar {
width: 32px;
@@ -776,10 +841,10 @@ pre {
border-bottom: 0;
}
/* A selected row keeps every pill visible: pills carry a tone border, and
- the row a jade bar. */
+ the row an amber bar (the current-item indicator). */
.resource-row.selected {
background: var(--selected);
- box-shadow: inset 3px 0 0 var(--jade);
+ box-shadow: inset 3px 0 0 var(--accent);
}
.resource-row .secondary {
font: var(--type-caption);
@@ -817,9 +882,9 @@ pre {
width: 40px;
height: 40px;
border: 1px solid var(--line);
- background: var(--mist);
+ background: var(--raised);
border-radius: 8px;
- color: var(--jade);
+ color: var(--muted);
flex: none;
}
.record-ref {
@@ -880,20 +945,15 @@ pre {
.step-icon {
width: 32px;
height: 32px;
- background: var(--mist);
+ background: var(--raised);
border: 1px solid var(--line);
- color: var(--jade);
+ color: var(--muted);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
flex: none;
}
-.human .step-icon {
- background: var(--warning-bg);
- border-color: var(--warning-bd);
- color: var(--warning-ink);
-}
.warning {
color: var(--warning-ink);
background: var(--warning-bg);
@@ -984,18 +1044,10 @@ dd {
.pair-brand {
display: flex;
align-items: center;
- gap: 16px;
- font-size: 20px;
- font-weight: 600;
}
.pair-brand img {
- width: 54px;
- height: 54px;
-}
-.pair-brand small {
- display: block;
- color: var(--muted);
- font-weight: 400;
+ width: 240px;
+ height: auto;
}
.pair-card {
background: var(--surface);
@@ -1007,13 +1059,9 @@ dd {
box-shadow: var(--shadow-1);
text-align: left;
}
-.pair-card .lumi {
- height: 110px;
- display: block;
- margin: 0 auto 24px;
-}
.pair-card h1 {
font: var(--type-display);
+ letter-spacing: -0.02em;
text-align: center;
}
.pair-card p {
@@ -1036,7 +1084,7 @@ dd {
.modal-backdrop {
position: fixed;
inset: 0;
- background: color-mix(in srgb, var(--forest) 50%, transparent);
+ background: var(--scrim);
z-index: 20;
display: flex;
align-items: center;
@@ -1152,7 +1200,7 @@ dd {
.notice.connection-ok {
background: var(--success-bg);
border-color: var(--success-bd);
- border-left-color: var(--jade);
+ border-left-color: var(--success);
color: var(--success-ink);
}
.notice[data-tone="danger"],
@@ -1261,7 +1309,7 @@ dd {
.comparison pre {
max-height: 320px;
overflow: auto;
- background: var(--mist);
+ background: var(--sunken);
padding: 16px;
border-radius: 6px;
}
@@ -1281,7 +1329,7 @@ dd {
width: 28px;
height: 28px;
border: 3px solid var(--line);
- border-top-color: var(--jade);
+ border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20px;
@@ -1300,10 +1348,12 @@ dd {
.collapsed .brand {
padding: 0 2px 28px;
}
-.collapsed .brand img {
- width: 44px;
+.collapsed .brand-lockup {
+ display: none;
+}
+.collapsed .brand-mark {
+ display: block;
}
-.collapsed .brand > span,
.collapsed .nav-group,
.collapsed .sidebar nav button span,
.collapsed .foundation,
@@ -1313,7 +1363,7 @@ dd {
.collapsed .nav-group + .nav-list {
margin-top: var(--space-2);
padding-top: var(--space-2);
- border-top: 1px solid var(--forest-hover);
+ border-top: 1px solid var(--line);
}
.collapsed .sidebar nav button {
padding: 12px;
@@ -1333,10 +1383,12 @@ dd {
.brand {
padding: 0 2px 28px;
}
- .brand img {
- width: 44px;
+ .brand-lockup {
+ display: none;
+ }
+ .brand-mark {
+ display: block;
}
- .brand > span,
.nav-group,
.sidebar nav button span,
.foundation,
@@ -1346,7 +1398,7 @@ dd {
.nav-group + .nav-list {
margin-top: var(--space-2);
padding-top: var(--space-2);
- border-top: 1px solid var(--forest-hover);
+ border-top: 1px solid var(--line);
}
.sidebar nav button {
padding: 12px;
@@ -1410,9 +1462,11 @@ dd {
.collapsed .brand {
padding: 0 2px 24px;
}
- .brand img,
- .collapsed .brand img {
+ .brand-mark,
+ .collapsed .brand-mark {
width: 38px;
+ height: 38px;
+ padding: 3px;
}
.sidebar nav button {
padding: 10px;
@@ -1524,9 +1578,10 @@ dd {
.collapsed .brand {
padding-bottom: 12px;
}
- .brand img {
+ .brand-mark {
width: 36px;
height: 36px;
+ padding: 2px;
}
.sidebar nav button {
height: 38px;
@@ -1637,7 +1692,7 @@ dd {
margin-bottom: 16px;
}
.mail-message.outbound {
- background: var(--mist);
+ background: var(--raised);
}
.mail-message header {
display: flex;
@@ -1720,7 +1775,7 @@ dd {
}
.pane-resizer:hover,
.pane-resizer:focus-visible {
- background: color-mix(in srgb, var(--mint) 31%, transparent);
+ background: var(--accent-glow);
}
.list-toolbar select {
font-size: 13px;
@@ -1748,9 +1803,9 @@ dd {
align-items: center;
justify-content: center;
gap: 8px;
- border: 2px solid var(--jade-strong);
+ border: 2px solid var(--node-border);
border-radius: 28px;
- color: var(--forest);
+ color: var(--text);
background: var(--selected);
font-weight: 700;
line-height: normal;
@@ -1849,7 +1904,7 @@ dd {
}
.integration-contract h4 {
font-size: 12px;
- color: var(--forest);
+ color: var(--text);
margin: 18px 0 8px;
text-transform: none;
}
@@ -2121,7 +2176,7 @@ input[type="file"][hidden] {
display: inline-block;
flex: none;
margin: 0;
- accent-color: var(--forest);
+ accent-color: var(--accent);
}
.start-options .hint,
.start-options .field-error {
@@ -2189,7 +2244,7 @@ input[type="file"][hidden] {
line-height: normal;
}
.start-node > button:hover {
- background: color-mix(in srgb, var(--mint) 40%, var(--surface));
+ background: var(--hover);
}
/* Durations: an amount and a unit. */
.duration-field {
@@ -2235,7 +2290,7 @@ input[type="file"][hidden] {
.split-button .new-menu-toggle {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
- border-left: 1px solid color-mix(in srgb, var(--on-dark) 35%, transparent);
+ border-left: 1px solid var(--line);
}
.integration-create button {
gap: 6px;
@@ -2313,9 +2368,9 @@ input[type="file"][hidden] {
}
/* "Current" marks the platform in use: emphasis, not a status. */
.state-chip.current {
- color: var(--on-dark);
- background: var(--forest);
- border-color: var(--forest);
+ color: var(--on-accent);
+ background: var(--accent);
+ border-color: var(--accent);
}
.state-chip.current::before {
display: none;
@@ -2335,8 +2390,8 @@ input[type="file"][hidden] {
white-space: nowrap;
}
.tag.emphasis {
- border-color: var(--forest);
- color: var(--forest);
+ border-color: var(--text);
+ color: var(--text);
font-family: var(--font-sans);
font-weight: 600;
}
@@ -2359,8 +2414,9 @@ input[type="file"][hidden] {
gap: var(--space-4);
padding: 12px 16px;
border-radius: var(--radius-md);
- background: var(--forest);
- color: var(--on-dark);
+ background: var(--raised);
+ border: 1px solid var(--border);
+ color: var(--text);
font: var(--type-body);
box-shadow: var(--shadow-2);
animation: toast-in var(--dur) var(--ease);
@@ -2370,11 +2426,11 @@ input[type="file"][hidden] {
padding: 0 8px;
background: transparent;
border: 0;
- color: var(--on-dark-accent);
+ color: var(--link);
font-weight: 600;
}
.toast button:hover {
- background: color-mix(in srgb, var(--on-dark) 8%, transparent);
+ background: var(--hover);
}
/* Over the focused control's place at the bottom: the toast shows on top. */
.toast-region.top {
@@ -2388,7 +2444,7 @@ input[type="file"][hidden] {
}
}
@media (max-width: 767px) {
- /* Beside the navigation rail, never over it (both are forest). */
+ /* Beside the navigation rail, never over it. */
.toast-region {
left: calc(var(--shell-left, 0px) + var(--space-4));
right: var(--space-4);
@@ -2517,7 +2573,7 @@ details.disclosure[open] > summary::before {
box-shadow: 0 0 0 6px var(--live-glow);
}
.is-live.pulse .node-body {
- animation: live-pulse 1.2s var(--ease) 1;
+ animation: live-pulse var(--dur-expressive) var(--ease) 1;
}
@keyframes live-pulse {
0% {
@@ -2592,7 +2648,7 @@ details.disclosure[open] > summary::before {
/* Keep the workflow name readable until its responsive maximum. */
.editor-identity h1 {
font: var(--type-title-sm);
- color: var(--forest);
+ color: var(--text);
margin: 0;
flex: 0 1 auto;
min-width: 0;
@@ -2638,12 +2694,12 @@ details.disclosure[open] > summary::before {
font-size: 13px;
}
.editor-views button:hover {
- color: var(--forest);
+ color: var(--text);
background: var(--hover);
}
.editor-views button[aria-selected="true"] {
background: var(--surface);
- color: var(--forest);
+ color: var(--text);
box-shadow: var(--shadow-1);
outline: 1px solid var(--border);
}
@@ -2764,7 +2820,7 @@ details.disclosure[open] > summary::before {
.editor-toolbar .inspector-toggle[aria-pressed="true"] {
background: var(--selected);
border-color: var(--border-hover);
- color: var(--forest);
+ color: var(--text);
}
/* Busy or blocked commands stay focusable; they look unavailable. */
.editor-toolbar button[aria-disabled="true"],
@@ -2969,7 +3025,7 @@ details.disclosure[open] > summary::before {
position: relative;
overflow: hidden;
container: canvas / inline-size;
- background-image: radial-gradient(var(--border) 0.8px, transparent 0.8px);
+ background-image: radial-gradient(var(--canvas-dot) 0.8px, transparent 0.8px);
background-size: 24px 24px;
touch-action: none;
}
@@ -3015,7 +3071,7 @@ details.disclosure[open] > summary::before {
.edges .edge-arrow {
fill: var(--flow-line);
}
-/* The live thread: the path the simulated run took is gold. */
+/* The live thread: the path the simulated run took is drawn in --live. */
.edges .edge.edge-taken {
stroke: var(--live);
stroke-width: 2.5;
@@ -3055,7 +3111,7 @@ details.disclosure[open] > summary::before {
.node-title {
display: block;
font: 600 max(14px, calc(12px / var(--zoom, 1))) / 1.2 var(--font-sans);
- color: var(--forest);
+ color: var(--text);
white-space: normal;
overflow-wrap: anywhere;
overflow: hidden;
@@ -3070,18 +3126,12 @@ details.disclosure[open] > summary::before {
text-overflow: ellipsis;
}
.graph-node.selected .node-body {
- border: 2px solid var(--jade);
- box-shadow: 0 0 0 4px color-mix(in srgb, var(--mint) 35%, transparent);
-}
-.graph-node.human .node-body {
- border-color: var(--warning-bd);
-}
-.graph-node.human.selected .node-body {
- border-color: var(--jade);
+ border: 2px solid var(--accent);
+ box-shadow: 0 0 0 4px var(--accent-glow);
}
/* Edits in the inspector that aren't applied yet. */
.graph-node.dirty .node-body {
- outline: 2px dashed var(--jade);
+ outline: 2px dashed var(--accent);
outline-offset: 3px;
}
/* Problems on the canvas: a border and a chip that names them. */
@@ -3127,12 +3177,12 @@ details.disclosure[open] > summary::before {
width: 20px;
height: 20px;
border-radius: 50%;
- background: var(--jade);
- color: var(--on-dark);
+ background: var(--success);
+ color: var(--on-accent);
border: 2px solid var(--surface);
}
.graph-node.is-done .node-body {
- border-color: var(--jade);
+ border-color: var(--node-border);
}
.graph-node.sim-breakpoint .node-body {
border-left: 4px solid var(--danger);
@@ -3155,8 +3205,8 @@ details.disclosure[open] > summary::before {
bottom: -6px;
}
.port.connecting {
- background: var(--jade);
- box-shadow: 0 0 0 4px var(--mint);
+ background: var(--accent);
+ box-shadow: 0 0 0 4px var(--accent-glow);
}
/* A branch's first step carries the branch's name: its condition,
"Otherwise" or the parallel branch's name. */
@@ -3168,8 +3218,8 @@ details.disclosure[open] > summary::before {
padding: 1px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-pill);
- background: var(--mist);
- color: var(--forest);
+ background: var(--raised);
+ color: var(--text);
font: 600 12px/16px var(--font-sans);
white-space: nowrap;
overflow: hidden;
@@ -3184,10 +3234,10 @@ details.disclosure[open] > summary::before {
min-height: 24px;
transform: translate(-50%, -50%);
padding: 0;
- border: 1.5px solid var(--jade);
+ border: 1.5px solid var(--flow-line);
border-radius: 50%;
background: var(--surface);
- color: var(--jade);
+ color: var(--muted);
line-height: normal;
pointer-events: auto;
display: grid;
@@ -3208,8 +3258,8 @@ details.disclosure[open] > summary::before {
.insertion-target:focus-visible,
.insertion-target.drop-active,
.insertion-target[aria-expanded="true"] {
- background: var(--jade);
- color: var(--on-dark);
+ background: var(--accent);
+ color: var(--on-accent);
}
.insertion-target:hover weave-icon,
.insertion-target:focus-visible weave-icon,
@@ -3243,7 +3293,7 @@ details.disclosure[open] > summary::before {
.insertion-target.branch-placeholder:focus-visible,
.insertion-target.branch-placeholder.drop-active {
background: var(--selected);
- border-color: var(--jade);
+ border-color: var(--accent);
color: var(--text);
}
.insertion-target.branch-placeholder weave-icon {
@@ -3261,7 +3311,7 @@ details.disclosure[open] > summary::before {
display: inline-flex;
align-items: center;
gap: 4px;
- color: var(--link);
+ color: var(--muted);
font-weight: 600;
white-space: nowrap;
}
@@ -3283,8 +3333,8 @@ details.disclosure[open] > summary::before {
width: 100%;
justify-content: center;
gap: 6px;
- border-color: var(--jade);
- color: var(--forest);
+ border-color: var(--field-border);
+ color: var(--text);
background: var(--surface);
}
.canvas-empty .first-step:hover {
@@ -3380,7 +3430,7 @@ details.disclosure[open] > summary::before {
opacity: 0.7;
}
.insertion-target.drop-hover {
- outline: 3px solid var(--jade);
+ outline: 3px solid var(--accent);
outline-offset: 4px;
}
.canvas-tools {
@@ -3594,7 +3644,7 @@ weave-llm-inspector .modal-panel.wide {
.human-deadlines > summary {
cursor: pointer;
font: var(--type-label);
- color: var(--forest);
+ color: var(--text);
}
.action-details > summary,
.slot-management > summary {
@@ -3612,7 +3662,7 @@ weave-llm-inspector .modal-panel.wide {
}
.section-body h4 {
font: var(--type-label);
- color: var(--forest);
+ color: var(--text);
margin: 16px 0 8px;
}
.inspector-section > summary {
@@ -3624,7 +3674,7 @@ weave-llm-inspector .modal-panel.wide {
background: var(--surface);
font: var(--type-title-sm);
font-size: 14px;
- color: var(--forest);
+ color: var(--text);
cursor: pointer;
list-style: none;
}
@@ -3907,7 +3957,7 @@ weave-llm-inspector .modal-panel.wide {
gap: 8px;
}
.outline button[aria-selected="true"] {
- border-color: var(--jade);
+ border-color: var(--accent);
background: var(--selected);
}
.outline button span {
@@ -3986,8 +4036,11 @@ kbd {
.inspector > .pane-resizer {
display: none;
}
+ /* The close button's hit area reaches into the header padding so its icon
+ lines up with the content edge and a long step name stays on one line. */
.inspector-close {
display: flex !important;
+ margin-right: calc(-1 * var(--space-2));
}
}
/* Narrower windows: the bar wraps its commands onto a second row. */
@@ -4158,7 +4211,7 @@ kbd {
.lane-header {
position: absolute;
padding: 4px 8px;
- color: var(--forest);
+ color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
@@ -4205,7 +4258,7 @@ kbd {
margin-right: 8px;
}
.workflow-progress [aria-current="step"] {
- color: var(--forest);
+ color: var(--text);
font-weight: 700;
}
.canvas.tiny-overview .insertion-target,
diff --git a/studio/tests/browser/ai-setup.spec.ts b/studio/tests/browser/ai-setup.spec.ts
index a09d85be..ddb24edd 100644
--- a/studio/tests/browser/ai-setup.spec.ts
+++ b/studio/tests/browser/ai-setup.spec.ts
@@ -95,7 +95,7 @@ async function setup(
} else
await r.fulfill({
status: 404,
- json: { code: "WV-NOT-FOUND", message: "Lumi is not configured" },
+ json: { code: "WV-NOT-FOUND", message: "Weave AI is not configured" },
});
});
return { connections, configurations, creates };
@@ -103,7 +103,7 @@ async function setup(
for (const width of [1440, 600])
test.describe(`AI setup at ${width}`, () => {
test.use({ viewport: { width, height: 800 } });
- test("fresh Azure provider connection and Lumi settings use named revisions and approved handles", async ({
+ test("fresh Azure provider connection and Weave AI settings use named revisions and approved handles", async ({
page,
}) => {
const capture = await setup(page);
@@ -164,10 +164,15 @@ for (const width of [1440, 600])
allowed_destinations: ["https://approved-models.openai.azure.com"],
});
await form.getByRole("button", { name: "Done", exact: true }).click();
- await page.getByRole("button", { name: "Ask Lumi", exact: true }).click();
- const lumi = page.getByRole("dialog", { name: "Ask Lumi", exact: true });
+ await page
+ .getByRole("button", { name: "Ask Weave AI", exact: true })
+ .click();
+ const lumi = page.getByRole("dialog", {
+ name: "Ask Weave AI",
+ exact: true,
+ });
await lumi
- .getByRole("button", { name: "Lumi settings", exact: true })
+ .getByRole("button", { name: "Weave AI settings", exact: true })
.click();
await selectChoice(
lumi.getByLabel("Provider", { exact: true }),
@@ -190,9 +195,9 @@ for (const width of [1440, 600])
.getByRole("button", { name: "Review settings", exact: true })
.click();
await lumi
- .getByRole("button", { name: "Save Lumi settings", exact: true })
+ .getByRole("button", { name: "Save Weave AI settings", exact: true })
.click();
- await expect(lumi.getByLabel("Message to Lumi")).toBeVisible();
+ await expect(lumi.getByLabel("Message to Weave AI")).toBeVisible();
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
@@ -213,7 +218,7 @@ for (const width of [1440, 600])
);
await expect(lumi).toContainText("gateway");
});
- test("fresh workflow AI profile and connection slot remain separate from Lumi", async ({
+ test("fresh workflow AI profile and connection slot remain separate from Weave AI", async ({
page,
}) => {
const capture = await setup(page);
@@ -278,14 +283,14 @@ for (const width of [1440, 600])
expect(capture.configurations).toEqual([]);
});
});
-test("Lumi manager without connection management sees the required grant instead of an editable UUID", async ({
+test("Weave AI manager without connection management sees the required grant instead of an editable UUID", async ({
page,
}) => {
const capture = await setup(page, ["lumi.use", "lumi.manage"]);
- await page.getByRole("button", { name: "Ask Lumi", exact: true }).click();
- const lumi = page.getByRole("dialog", { name: "Ask Lumi", exact: true });
+ await page.getByRole("button", { name: "Ask Weave AI", exact: true }).click();
+ const lumi = page.getByRole("dialog", { name: "Ask Weave AI", exact: true });
await lumi
- .getByRole("button", { name: "Lumi settings", exact: true })
+ .getByRole("button", { name: "Weave AI settings", exact: true })
.click();
await expect(lumi).toContainText("connection.manage");
await selectChoice(
@@ -315,15 +320,15 @@ test("Settings exposes admin AI setup while viewers cannot configure or spend",
await setup(page);
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page
- .getByRole("button", { name: "Configure Lumi", exact: true })
+ .getByRole("button", { name: "Weave AI settings", exact: true })
.click();
await expect(
page.getByRole("heading", {
- name: "Choose Lumi's model",
+ name: "Choose Weave AI's model",
exact: true,
}),
).toBeVisible();
- const lumi = page.getByRole("dialog", { name: "Ask Lumi", exact: true });
+ const lumi = page.getByRole("dialog", { name: "Ask Weave AI", exact: true });
await selectChoice(
lumi.getByLabel("Provider", { exact: true }),
"openai-responses",
@@ -343,15 +348,15 @@ test("viewers see setup guidance without manager controls", async ({
const capture = await setup(page, ["catalog.read"]);
await page.getByRole("button", { name: "Settings", exact: true }).click();
await expect(
- page.getByRole("button", { name: "Configure Lumi", exact: true }),
+ page.getByRole("button", { name: "Weave AI settings", exact: true }),
).toHaveCount(0);
await expect(
page.getByRole("button", { name: "New AI connection", exact: true }),
).toHaveCount(0);
- await page.getByRole("button", { name: "Ask Lumi", exact: true }).click();
- const panel = page.getByRole("dialog", { name: "Ask Lumi", exact: true });
+ await page.getByRole("button", { name: "Ask Weave AI", exact: true }).click();
+ const panel = page.getByRole("dialog", { name: "Ask Weave AI", exact: true });
await expect(
- panel.getByRole("button", { name: "Lumi settings", exact: true }),
+ panel.getByRole("button", { name: "Weave AI settings", exact: true }),
).toHaveCount(0);
await expect(
panel.getByRole("button", { name: "Send message", exact: true }),
@@ -399,9 +404,9 @@ test("AI model basics and advanced options preserve edits, invalid drafts and ex
});
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page
- .getByRole("button", { name: "Configure Lumi", exact: true })
+ .getByRole("button", { name: "Weave AI settings", exact: true })
.click();
- const lumi = page.getByRole("dialog", { name: "Ask Lumi", exact: true });
+ const lumi = page.getByRole("dialog", { name: "Ask Weave AI", exact: true });
await expect(lumi.getByLabel("Model", { exact: true })).toHaveValue(
"approved-model",
);
@@ -449,16 +454,16 @@ test("AI model basics and advanced options preserve edits, invalid drafts and ex
.getByRole("button", { name: "Review settings", exact: true })
.click();
await lumi
- .getByRole("button", { name: "Save Lumi settings", exact: true })
+ .getByRole("button", { name: "Save Weave AI settings", exact: true })
.click();
- await expect(lumi.getByLabel("Message to Lumi")).toBeVisible();
+ await expect(lumi.getByLabel("Message to Weave AI")).toBeVisible();
expect(capture.configurations[0].profile).toEqual({
...profile,
model: "updated-model",
options: { max_tokens: 2048, top_p: 0.8 },
});
await lumi
- .getByRole("button", { name: "Lumi settings", exact: true })
+ .getByRole("button", { name: "Weave AI settings", exact: true })
.click();
await lumi.getByText("Advanced model settings", { exact: true }).click();
await expect(
@@ -504,9 +509,9 @@ test("canonical model validation blocks pending and invalid settings and ignores
});
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page
- .getByRole("button", { name: "Configure Lumi", exact: true })
+ .getByRole("button", { name: "Weave AI settings", exact: true })
.click();
- const lumi = page.getByRole("dialog", { name: "Ask Lumi", exact: true });
+ const lumi = page.getByRole("dialog", { name: "Ask Weave AI", exact: true });
await selectChoice(
lumi.getByLabel("Provider", { exact: true }),
"openai-responses",
diff --git a/studio/tests/browser/deployment-operations.spec.ts b/studio/tests/browser/deployment-operations.spec.ts
index 32313835..428b468f 100644
--- a/studio/tests/browser/deployment-operations.spec.ts
+++ b/studio/tests/browser/deployment-operations.spec.ts
@@ -18,7 +18,7 @@ SPDX-License-Identifier: Apache-2.0
import { readFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { test, expect } from "@playwright/test";
-import { connected, offline, selectChoice } from "./support";
+import { connected, offline, selectChoice, tokenColor } from "./support";
const schema = JSON.parse(
execFileSync(
@@ -845,6 +845,24 @@ for (const width of [1440, 390])
await expect(
page.getByText("Fixture reload unavailable", { exact: true }),
).toBeVisible();
+ // A failure is a danger notice: red bar, red ink on the red tint. The
+ // amber bar stays on the plain notices (it is the current-item cue).
+ const alert = page.getByRole("alert").filter({
+ hasText: "Fixture reload unavailable",
+ });
+ const look = await alert.evaluate((e) => {
+ const style = getComputedStyle(e);
+ return {
+ bar: style.borderLeftColor,
+ color: style.color,
+ background: style.backgroundColor,
+ };
+ });
+ expect(look).toEqual({
+ bar: await tokenColor(page, "--danger"),
+ color: await tokenColor(page, "--danger-ink"),
+ background: await tokenColor(page, "--danger-bg"),
+ });
await expect(
page.getByRole("button", {
name: "Save target authority",
@@ -1346,7 +1364,9 @@ for (const width of [1440, 390]) {
.click();
const planner = page.locator("weave-deployment-plan-builder");
await expect(
- planner.getByText(/Container Apps updates support worker and Lumi apps/),
+ planner.getByText(
+ /Container Apps updates support worker and Weave AI apps/,
+ ),
).toBeVisible();
await expect(
planner.getByRole("button", {
@@ -1373,11 +1393,11 @@ for (const width of [1440, 390]) {
page.getByRole("option", { name: "api", exact: true }),
).toBeVisible();
await expect(
- page.getByRole("button", { name: "Explain with Lumi", exact: true }),
+ page.getByRole("button", { name: "Explain with Weave AI", exact: true }),
).toHaveCount(0);
});
- test(`Explain with Lumi offers only unchecked selected target context at ${width}`, async ({
+ test(`Explain with Weave AI offers only unchecked selected target context at ${width}`, async ({
page,
}) => {
await page.setViewportSize({ width, height: 900 });
@@ -1410,7 +1430,7 @@ for (const width of [1440, 390]) {
});
await page.goto("/operations/targets/" + targetId);
await page
- .getByRole("button", { name: "Explain with Lumi", exact: true })
+ .getByRole("button", { name: "Explain with Weave AI", exact: true })
.click();
await expect(
page.getByRole("checkbox", {
diff --git a/studio/tests/browser/designer-canvas-view.spec.ts b/studio/tests/browser/designer-canvas-view.spec.ts
index e6c04c54..98694d8d 100644
--- a/studio/tests/browser/designer-canvas-view.spec.ts
+++ b/studio/tests/browser/designer-canvas-view.spec.ts
@@ -22,7 +22,6 @@ SPDX-License-Identifier: Apache-2.0
import { test, expect, type Page } from "@playwright/test";
import { expectHitTarget, insertStep, newWorkflow, offline } from "./support";
import { DesignerPage } from "./designer-po";
-import { iconPaths } from "../../src/app/icon";
/** A workflow of twelve steps in the main sequence. */
const twelve = `apiVersion: weave/v1alpha1
kind: Workflow
@@ -307,10 +306,10 @@ test.describe("1440x900", () => {
);
// The icon follows the result: a failure mark, never a check.
await expect(
- strip.locator(`svg path[d="${iconPaths["failCircle"]}"]`).first(),
+ strip.locator('weave-icon[data-icon="failCircle"]').first(),
).toBeAttached();
await expect(
- strip.locator(`.diagnostics-title svg path[d="${iconPaths["check"]}"]`),
+ strip.locator('.diagnostics-title weave-icon[data-icon="check"]'),
).toHaveCount(0);
// The steps say it too.
await expect(page.locator('[data-step="wait-1"] .node-chip')).toHaveText(
diff --git a/studio/tests/browser/foundation.spec.ts b/studio/tests/browser/foundation.spec.ts
index a1fe8c47..84e77daf 100644
--- a/studio/tests/browser/foundation.spec.ts
+++ b/studio/tests/browser/foundation.spec.ts
@@ -15,9 +15,11 @@ limitations under the License.
Author: Firefly Software Foundation
SPDX-License-Identifier: Apache-2.0
*/
-// The design foundation (UX plan wave 1): field borders that pass 3:1, flat
-// disabled buttons, one focus ring, status pills with a tone border, control
-// heights, and one drawing per icon.
+// The design foundation (brand PR 1, contract C1): field borders that pass
+// 3:1, flat disabled buttons, one amber focus ring, status pills in the tone
+// tokens, Lucide icons at a 1.5px stroke, Manrope from Studio's own origin,
+// and the Firefly Weave lockup, mark and favicons. Colors are read from the
+// tokens at run time; only the amber accent is a fixed brand anchor.
import { test, expect, Locator, Page } from "@playwright/test";
import {
allCapabilities,
@@ -25,9 +27,13 @@ import {
insertStep,
newWorkflow,
offline,
+ tokenColor,
} from "./support";
import { DesignerPage } from "./designer-po";
+/** The brand amber (--accent, --focus): a fixed anchor, not read from CSS. */
+const amber = "rgb(255, 179, 74)";
+
/** WCAG contrast of two computed colors ("rgb(r, g, b)"). */
function contrast(a: string, b: string) {
const luminance = (value: string) => {
@@ -62,8 +68,6 @@ async function tabTo(page: Page, selector: string, limit = 40) {
}
throw Error(`Tab never reached ${selector}`);
}
-const white = "rgb(255, 255, 255)";
-const mist = "rgb(238, 244, 240)";
test.describe("1440x900", () => {
test.use({ viewport: { width: 1440, height: 900 } });
@@ -74,10 +78,13 @@ test.describe("1440x900", () => {
await offline(page);
await newWorkflow(page);
const name = new DesignerPage(page).inspectorField("Name");
- const rest = await css(name, "border-top-color");
- expect(rest["border-top-color"]).toBe("rgb(115, 140, 128)");
- expect(contrast(rest["border-top-color"], white)).toBeGreaterThanOrEqual(3);
- expect(contrast(rest["border-top-color"], mist)).toBeGreaterThanOrEqual(3);
+ const rest = (await css(name, "border-top-color"))["border-top-color"];
+ expect(rest).toBe(await tokenColor(page, "--field-border"));
+ for (const surface of ["--sunken", "--surface", "--raised", "--hover"])
+ expect(
+ contrast(rest, await tokenColor(page, surface)),
+ surface,
+ ).toBeGreaterThanOrEqual(3);
await name.click();
const focused = await css(
name,
@@ -90,12 +97,39 @@ test.describe("1440x900", () => {
expect(focused).toEqual({
"outline-style": "solid",
"outline-width": "2px",
- "outline-color": "rgb(44, 106, 87)",
+ "outline-color": amber,
"outline-offset": "1px",
- "border-top-color": "rgb(44, 106, 87)",
+ "border-top-color": amber,
});
});
+ test("placeholders are drawn in --subtle at 4.5:1, not Chromium's grey", async ({
+ page,
+ }) => {
+ await offline(page);
+ await newWorkflow(page);
+ const search = page.getByPlaceholder("Search steps");
+ await expect(search).toBeVisible();
+ const look = await search.evaluate((input) => ({
+ color: getComputedStyle(input, "::placeholder").color,
+ opacity: getComputedStyle(input, "::placeholder").opacity,
+ background: getComputedStyle(input).backgroundColor,
+ }));
+ expect(look.color).toBe(await tokenColor(page, "--subtle"));
+ expect(look.opacity).toBe("1");
+ expect(contrast(look.color, look.background)).toBeGreaterThanOrEqual(4.5);
+ });
+
+ test("a misspelled token fails the test instead of passing by inheritance", async ({
+ page,
+ }) => {
+ await offline(page);
+ await expect(tokenColor(page, "--sutble")).rejects.toThrow(
+ "Design token --sutble is not defined on :root",
+ );
+ await expect(tokenColor(page, "--subtle")).resolves.toMatch(/^rgb\(/);
+ });
+
test("a disabled primary is flat and legible, never a pressed slab", async ({
page,
}) => {
@@ -111,26 +145,27 @@ test.describe("1440x900", () => {
await expect(activate).toBeDisabled();
await expect(activate).toHaveAttribute("aria-disabled", "true");
await expect(activate).toHaveClass(/\bprimary\b/);
- const look = await css(activate, "background-color", "color", "opacity");
- expect(look).toEqual({
- "background-color": "rgb(238, 242, 239)",
- color: "rgb(92, 112, 106)",
+ const flat = {
+ "background-color": await tokenColor(page, "--disabled-bg"),
+ color: await tokenColor(page, "--disabled-ink"),
opacity: "1",
- });
+ };
+ const look = await css(activate, "background-color", "color", "opacity");
+ expect(look).toEqual(flat);
expect(contrast(look.color, look["background-color"])).toBeGreaterThan(4.5);
// Hovering changes nothing.
await activate.hover({ force: true });
expect((await css(activate, "background-color"))["background-color"]).toBe(
- "rgb(238, 242, 239)",
+ flat["background-color"],
);
});
- test("one 2px focus ring, mint in the forest sidebar", async ({ page }) => {
+ test("one 2px amber focus ring, in the sidebar too", async ({ page }) => {
await offline(page);
// The first stops are in the sidebar.
const sidebar = await tabTo(page, ".sidebar button");
expect(await css(sidebar, "outline-color", "outline-width")).toEqual({
- "outline-color": "rgb(161, 209, 185)",
+ "outline-color": amber,
"outline-width": "2px",
});
const content = await tabTo(page, ".main-shell button");
@@ -145,7 +180,7 @@ test.describe("1440x900", () => {
).toEqual({
"outline-style": "solid",
"outline-width": "2px",
- "outline-color": "rgb(44, 106, 87)",
+ "outline-color": amber,
"outline-offset": "2px",
});
});
@@ -155,7 +190,6 @@ test.describe("1440x900", () => {
}) => {
await offline(page);
await expect(page.locator(".dashboard-status")).toBeVisible();
- // Waves 2 and 3 apply these classes; their look is defined now.
const looks = await page.evaluate(() => {
const host = document.createElement("div");
host.innerHTML = `
@@ -191,37 +225,45 @@ test.describe("1440x900", () => {
return result;
});
const by = (text: string) => looks.find((l) => l.text === text)!;
+ const token = (name: string) => tokenColor(page, name);
expect(by("Failed")).toMatchObject({
- color: "rgb(150, 37, 49)",
- background: "rgb(251, 232, 233)",
+ color: await token("--danger-ink"),
+ background: await token("--danger-bg"),
borderWidth: "1px",
height: "22px",
font: "600 12px",
dot: '""',
});
- expect(by("Succeeded").color).toBe("rgb(29, 96, 71)");
- expect(by("Running").color).toBe("rgb(36, 90, 107)");
- expect(by("Waiting").color).toBe("rgb(115, 80, 15)");
+ expect(by("Succeeded").color).toBe(await token("--success-ink"));
+ expect(by("Running").color).toBe(await token("--info-ink"));
+ expect(by("Waiting").color).toBe(await token("--warning-ink"));
// The old class name is the neutral pill.
expect(by("Queued")).toMatchObject({
- color: "rgb(70, 93, 85)",
- background: "rgb(237, 241, 238)",
+ color: await token("--neutral-ink"),
+ background: await token("--neutral-bg"),
});
for (const text of ["Failed", "Succeeded", "Running", "Waiting", "Queued"])
expect(
contrast(by(text).color, by(text).background),
).toBeGreaterThanOrEqual(6);
- // "Production" is a forest outline, never gold.
+ // "Production" is a paper outline: never amber, never a status tone.
expect(by("Production")).toMatchObject({
- color: "rgb(23, 61, 52)",
- border: "rgb(23, 61, 52)",
+ color: await token("--text"),
+ border: await token("--text"),
+ });
+ expect(by("Created pets.").background).toBe(await token("--success-bg"));
+ expect(by("Not saved.").color).toBe(await token("--danger-ink"));
+ // Toasts are raised surfaces with paper text and a graphite edge.
+ expect(by("Task claimed.")).toMatchObject({
+ background: await token("--raised"),
+ color: await token("--text"),
+ border: await token("--border"),
});
- expect(by("Created pets.").background).toBe("rgb(226, 241, 233)");
- expect(by("Not saved.").color).toBe("rgb(150, 37, 49)");
- expect(by("Task claimed.").background).toBe("rgb(23, 61, 52)");
});
- test("each icon has its own drawing, at 16 and 20 px", async ({ page }) => {
+ test("icons draw Lucide geometry at a 1.5px stroke, 16 and 20 px", async ({
+ page,
+ }) => {
await offline(page);
await newWorkflow(page);
await insertStep(page, "Wait for signal");
@@ -229,44 +271,185 @@ test.describe("1440x900", () => {
const icons = await page
.locator("weave-icon[data-icon]")
.evaluateAll((elements) =>
- elements.map((e) => ({
- name: e.getAttribute("data-icon")!,
- d: e.querySelector("path")!.getAttribute("d")!,
- rings: e.querySelectorAll("circle").length,
- size: Math.round(e.getBoundingClientRect().width),
- stroke: getComputedStyle(e.querySelector("svg")!).strokeWidth,
- scaling: getComputedStyle(e.querySelector("path")!).vectorEffect,
- })),
+ elements.map((e) => {
+ const svg = e.querySelector("svg")!;
+ return {
+ name: e.getAttribute("data-icon")!,
+ missing: e.hasAttribute("data-icon-missing"),
+ // Angular's control-flow anchors are comments; the drawing is the rest.
+ drawing: svg.innerHTML.replace(//g, ""),
+ first: svg.firstElementChild?.getAttribute("d") ?? "",
+ size: Math.round(e.getBoundingClientRect().width),
+ stroke: getComputedStyle(svg).strokeWidth,
+ scaling: [...svg.children].map(
+ (c) => getComputedStyle(c).vectorEffect,
+ ),
+ };
+ }),
);
+ expect(icons.filter((i) => i.missing).map((i) => i.name)).toEqual([]);
const drawings = new Map();
for (const icon of icons) {
- const drawing = `${icon.d}|${icon.rings}`;
- const other = drawings.get(drawing);
+ const other = drawings.get(icon.drawing);
if (other)
expect(other, `${icon.name} draws like ${other}`).toBe(icon.name);
- drawings.set(drawing, icon.name);
+ drawings.set(icon.drawing, icon.name);
}
- // The gear, the radio waves and a whole head.
- const settings = icons.find((i) => i.name === "settings")!;
- expect(settings.d.startsWith("M12 15a3 3 0 1 0 0-6")).toBe(true);
- expect(settings.rings).toBe(0);
- expect(icons.find((i) => i.name === "signal")!.d).not.toBe(
- icons.find((i) => i.name === "email")!.d,
- );
- // Sizes and strokes: 20 px at 1.75, 16 px at 1.5, never scaled.
+ // Lucide's gear; the signal's radio tower is not the email envelope.
+ const named = (name: string) => icons.find((i) => i.name === name)!;
+ expect(named("settings").first).toMatch(/^M9\.671 4\.136/);
+ expect(named("signal").drawing).not.toBe(named("email").drawing);
+ // One stroke at every size (16, 20 and CSS-sized), never scaled with the drawing.
+ expect(icons.some((i) => i.size === 16)).toBe(true);
+ expect(icons.some((i) => i.size === 20)).toBe(true);
for (const icon of icons) {
- expect(icon.scaling, icon.name).toBe("non-scaling-stroke");
- if (icon.size === 20) expect(icon.stroke, icon.name).toBe("1.75px");
+ expect(icon.stroke, icon.name).toBe("1.5px");
+ expect(new Set(icon.scaling), icon.name).toEqual(
+ new Set(["non-scaling-stroke"]),
+ );
}
- const chevron = page.locator(".platform-indicator .menu-chevron");
- expect(await chevron.evaluate((e) => e.getBoundingClientRect().width)).toBe(
- 16,
+ });
+
+ test("renders Manrope from Studio's own origin", async ({ page }) => {
+ const fonts: string[] = [];
+ page.on("request", (request) => {
+ if (request.resourceType() === "font") fonts.push(request.url());
+ });
+ await offline(page);
+ await page.evaluate(() => document.fonts.ready);
+ expect(
+ await page.evaluate(() => document.fonts.check("14px Manrope")),
+ ).toBe(true);
+ expect(
+ await page.evaluate(() => getComputedStyle(document.body).fontFamily),
+ ).toMatch(/^"?Manrope"?,/);
+ const origin = new URL(page.url()).origin;
+ expect(fonts.length).toBeGreaterThan(0);
+ for (const url of fonts)
+ expect(url.startsWith(`${origin}/fonts/manrope/`), url).toBe(true);
+ const latin = await page.request.get(
+ "/fonts/manrope/manrope-latin-wght-normal.woff2",
);
+ expect(latin.headers()["content-type"]).toMatch(/^font\/woff2/);
+ });
+
+ test("asks the browser for dark native controls", async ({ page }) => {
+ await offline(page);
+ expect(
+ await page.evaluate(() => ({
+ root: getComputedStyle(document.documentElement).colorScheme,
+ meta: document
+ .querySelector('meta[name="color-scheme"]')
+ ?.getAttribute("content"),
+ })),
+ ).toEqual({ root: "dark", meta: "dark" });
+ });
+
+ test("loads the brand files on a nested route", async ({ page }) => {
+ await offline(page);
+ await page.goto("/operations/targets/example");
+ const lockup = page
+ .getByRole("link", { name: "Firefly Weave Studio home" })
+ .locator("img.brand-lockup");
+ await expect(lockup).toBeVisible();
+ expect(
+ await lockup.evaluate(
+ (e: HTMLImageElement) => e.complete && e.naturalWidth > 0,
+ ),
+ ).toBe(true);
+ await page.evaluate(() => document.fonts.ready);
+ expect(
+ await page.evaluate(() => document.fonts.check("14px Manrope")),
+ ).toBe(true);
+ });
+
+ test("shows the lockup, the mark when narrow, and the favicon set", async ({
+ page,
+ }) => {
+ await offline(page);
+ const home = page.getByRole("link", { name: "Firefly Weave Studio home" });
+ const lockup = home.locator("img.brand-lockup");
+ const mark = home.locator("img.brand-mark");
+ const drawn = (image: Locator) =>
+ image.evaluate((e: HTMLImageElement) => ({
+ loaded: e.complete && e.naturalWidth > 0,
+ width: e.getBoundingClientRect().width,
+ padding: getComputedStyle(e).paddingLeft,
+ }));
+ await expect(lockup).toBeVisible();
+ await expect(mark).toBeHidden();
+ expect(await drawn(lockup)).toMatchObject({ loaded: true, width: 200 });
+ // The box is reserved before the SVG loads (200 x 248.25/1427.4 = 34.8).
+ await expect(lockup).toHaveAttribute("width", "200");
+ await expect(lockup).toHaveAttribute("height", "35");
+ // At 1280px and below the sidebar narrows: the 32px mark in a 44px box.
+ await page.setViewportSize({ width: 1280, height: 720 });
+ await expect(lockup).toBeHidden();
+ await expect(mark).toBeVisible();
+ expect(await drawn(mark)).toEqual({
+ loaded: true,
+ width: 44,
+ padding: "6px",
+ });
+ const head = await page.evaluate(() => ({
+ icons: [
+ ...document.querySelectorAll(
+ 'link[rel="icon"], link[rel="apple-touch-icon"]',
+ ),
+ ].map((link) => link.getAttribute("href")),
+ manifests: document.querySelectorAll('link[rel="manifest"]').length,
+ theme: document
+ .querySelector('meta[name="theme-color"]')
+ ?.getAttribute("content"),
+ scheme: document
+ .querySelector('meta[name="color-scheme"]')
+ ?.getAttribute("content"),
+ }));
+ expect(head).toEqual({
+ icons: ["favicon.ico", "favicon.svg", "apple-touch-icon.png"],
+ manifests: 0,
+ theme: "#10110f",
+ scheme: "dark",
+ });
+ for (const file of [
+ "/favicon.ico",
+ "/favicon.svg",
+ "/apple-touch-icon.png",
+ ])
+ expect(
+ (await page.request.get(file)).headers()["content-type"],
+ file,
+ ).toMatch(/^image\//);
+ });
+
+ test("pairing shows the lockup on charcoal", async ({ page }) => {
+ await page.route("**/studio/session", (r) =>
+ r.fulfill({
+ json: { paired: false, version: "1", mode: "offline", profile: null },
+ }),
+ );
+ await page.goto("/");
+ const lockup = page.getByRole("img", {
+ name: "Firefly Weave",
+ exact: true,
+ });
+ await expect(lockup).toBeVisible();
+ expect(
+ await lockup.evaluate((e: HTMLImageElement) => ({
+ src: new URL(e.src).pathname,
+ loaded: e.complete && e.naturalWidth > 0,
+ width: e.getBoundingClientRect().width,
+ })),
+ ).toEqual({
+ src: "/assets/weave-lockup-reversed.svg",
+ loaded: true,
+ width: 240,
+ });
expect(
- await chevron.evaluate(
- (e) => getComputedStyle(e.querySelector("svg")!).strokeWidth,
+ await page.evaluate(
+ () => getComputedStyle(document.documentElement).backgroundColor,
),
- ).toBe("1.5px");
+ ).toBe(await tokenColor(page, "--bg"));
});
});
diff --git a/studio/tests/browser/http-connection.spec.ts b/studio/tests/browser/http-connection.spec.ts
index 18c66f3b..3bd31120 100644
--- a/studio/tests/browser/http-connection.spec.ts
+++ b/studio/tests/browser/http-connection.spec.ts
@@ -21,7 +21,7 @@ SPDX-License-Identifier: Apache-2.0
// .
import { selectChoice } from "./support";
import { test, expect, Page, Request } from "@playwright/test";
-import { allCapabilities, connected, profile } from "./support";
+import { allCapabilities, connected, profile, tokenColor } from "./support";
const digest =
"eddfa829184f8505fd0e1bc7a84b490fc57b555a39495b9724b2728b277133d8";
@@ -267,10 +267,11 @@ test("an invalid field keeps its red border, and the focus ring clears its label
width: parseFloat(s.outlineWidth),
};
});
- // Red stays red while focused; the green ring is drawn outside it.
- expect(look.border).toBe("rgb(161, 43, 53)");
- expect(look.bar).toContain("rgb(161, 43, 53)");
- expect(look.outline).toBe("solid 2px rgb(44, 106, 87)");
+ // The danger edge stays while focused; the amber ring is drawn outside it.
+ const danger = await tokenColor(page, "--danger");
+ expect(look.border).toBe(danger);
+ expect(look.bar).toContain(danger);
+ expect(look.outline).toBe(`solid 2px ${await tokenColor(page, "--focus")}`);
// The ring's box never covers the field's label.
const field = (await secret.boundingBox())!;
const ring = look.offset + look.width;
diff --git a/studio/tests/browser/layout-fixes.spec.ts b/studio/tests/browser/layout-fixes.spec.ts
index f95c5a5a..1a6ac49a 100644
--- a/studio/tests/browser/layout-fixes.spec.ts
+++ b/studio/tests/browser/layout-fixes.spec.ts
@@ -19,7 +19,13 @@ SPDX-License-Identifier: Apache-2.0
// holds one fix at the size where the defect showed.
import { selectChoice } from "./support";
import { test, expect, Locator, Page } from "@playwright/test";
-import { allCapabilities, connected, newWorkflow, offline } from "./support";
+import {
+ allCapabilities,
+ connected,
+ newWorkflow,
+ offline,
+ tokenColor,
+} from "./support";
import { DesignerPage } from "./designer-po";
import { acmePlatform, platformHost } from "./platform-host";
@@ -394,7 +400,7 @@ test.describe("1440x900", () => {
(e) => getComputedStyle(e).backgroundColor,
);
// --success-bg, the success tone, not the caution gold of a plain notice.
- expect(background).toBe("rgb(226, 241, 233)");
+ expect(background).toBe(await tokenColor(page, "--success-bg"));
});
test("a selected row keeps a visible status pill", async ({ page }) => {
diff --git a/studio/tests/browser/lumi-operations.spec.ts b/studio/tests/browser/lumi-operations.spec.ts
index 8fe4764d..f5f5227f 100644
--- a/studio/tests/browser/lumi-operations.spec.ts
+++ b/studio/tests/browser/lumi-operations.spec.ts
@@ -86,7 +86,7 @@ for (const width of [1440, 390]) {
.getByRole("button", { name: "First target", exact: true })
.click();
await page
- .getByRole("button", { name: "Explain with Lumi", exact: true })
+ .getByRole("button", { name: "Explain with Weave AI", exact: true })
.click();
const panel = page.locator("weave-lumi-panel");
await expect(
@@ -97,7 +97,7 @@ for (const width of [1440, 390]) {
).toBeVisible();
expect(sent).toEqual([]);
await panel
- .getByLabel("Message to Lumi", { exact: true })
+ .getByLabel("Message to Weave AI", { exact: true })
.fill("Explain generally");
await panel
.getByRole("button", { name: "Send message", exact: true })
@@ -111,7 +111,7 @@ for (const width of [1440, 390]) {
).toHaveCount(0);
await panel.getByLabel("Include selected target", { exact: true }).check();
await panel
- .getByLabel("Message to Lumi", { exact: true })
+ .getByLabel("Message to Weave AI", { exact: true })
.fill("Explain this target");
await panel
.getByRole("button", { name: "Send message", exact: true })
@@ -125,7 +125,7 @@ for (const width of [1440, 390]) {
]);
expect(JSON.stringify(sent[1])).not.toContain("private-");
await panel
- .getByRole("button", { name: "Close Lumi", exact: true })
+ .getByRole("button", { name: "Close Weave AI", exact: true })
.click();
await page
.getByRole("button", { name: "Back to Operations", exact: true })
@@ -134,7 +134,7 @@ for (const width of [1440, 390]) {
.getByRole("button", { name: "Second target", exact: true })
.click();
await page
- .getByRole("button", { name: "Explain with Lumi", exact: true })
+ .getByRole("button", { name: "Explain with Weave AI", exact: true })
.click();
await expect(
panel.getByLabel("Include selected target", { exact: true }),
diff --git a/studio/tests/browser/member-admin.spec.ts b/studio/tests/browser/member-admin.spec.ts
index 58b00c5c..8ed517d5 100644
--- a/studio/tests/browser/member-admin.spec.ts
+++ b/studio/tests/browser/member-admin.spec.ts
@@ -15,7 +15,7 @@ limitations under the License.
Author: Firefly Software Foundation
SPDX-License-Identifier: Apache-2.0
*/
-import { selectChoice } from "./support";
+import { selectChoice, tokenColor } from "./support";
import { test, expect, Page } from "@playwright/test";
const tenant = "00000000-0000-0000-0000-000000000001";
const project = "00000000-0000-0000-0000-000000000002";
@@ -186,7 +186,11 @@ test("People and access reads by account and role; tables share one look", async
const s = getComputedStyle(e);
return [s.fontSize, s.backgroundColor, s.color];
});
- expect(head).toEqual(["12px", "rgb(248, 250, 248)", "rgb(77, 101, 93)"]);
+ expect(head).toEqual([
+ "12px",
+ await tokenColor(page, "--sunken"),
+ await tokenColor(page, "--muted"),
+ ]);
const cell = await table.locator("td").first().boundingBox();
expect(cell!.height).toBeGreaterThanOrEqual(56);
// Assign role opens a side panel; its fields stack with their labels.
@@ -328,7 +332,7 @@ for (const viewport of [
{ width: 1440, height: 900 },
{ width: 600, height: 500 },
]) {
- test(`file and Lumi roles can be granted independently at ${viewport.width}`, async ({
+ test(`file and Weave AI roles can be granted independently at ${viewport.width}`, async ({
page,
}) => {
await page.setViewportSize(viewport);
diff --git a/studio/tests/browser/studio.spec.ts b/studio/tests/browser/studio.spec.ts
index 5b8e667c..c17257fb 100644
--- a/studio/tests/browser/studio.spec.ts
+++ b/studio/tests/browser/studio.spec.ts
@@ -15,7 +15,7 @@ limitations under the License.
Author: Firefly Software Foundation
SPDX-License-Identifier: Apache-2.0
*/
-import { selectChoice } from "./support";
+import { selectChoice, tokenColor } from "./support";
import { test, expect } from "@playwright/test";
async function offline(page: any) {
await page.route("**/studio/session", async (route: any) =>
@@ -283,7 +283,7 @@ test("parallel branches can be added and renamed while populated deletion is gua
).not.toHaveValue(/audit:/);
});
-test("Home disconnected badge remains light and visible with expanded and collapsed navigation", async ({
+test("Home disconnected badge stays visible with expanded and collapsed navigation", async ({
page,
}) => {
await offline(page);
@@ -295,8 +295,14 @@ test("Home disconnected badge remains light and visible with expanded and collap
await expect(badge).toHaveText("Local authoring");
await expect(badge).toBeVisible();
// The neutral status pill (--neutral-bg) with its tone border.
- await expect(badge).toHaveCSS("background-color", "rgb(237, 241, 238)");
- await expect(badge).toHaveCSS("border-top-color", "rgb(195, 208, 201)");
+ await expect(badge).toHaveCSS(
+ "background-color",
+ await tokenColor(page, "--neutral-bg"),
+ );
+ await expect(badge).toHaveCSS(
+ "border-top-color",
+ await tokenColor(page, "--neutral-bd"),
+ );
await expect(badge).not.toHaveCSS("min-height", "64px");
await page.screenshot({
path: `test-results/home-${width}.png`,
diff --git a/studio/tests/browser/support.ts b/studio/tests/browser/support.ts
index d504df4a..f8d58712 100644
--- a/studio/tests/browser/support.ts
+++ b/studio/tests/browser/support.ts
@@ -250,6 +250,28 @@ export async function connected(
await page.goto("/");
return recorder;
}
+/**
+ * A design token's color as the browser computes colors ("rgb(r, g, b)"),
+ * read from the page at run time so tests follow the token, not a copy of it.
+ */
+export async function tokenColor(page: Page, token: string): Promise {
+ return page.evaluate((name) => {
+ // An undefined token would make the probe inherit the body color, so a
+ // misspelled name could pass by accident: refuse it instead.
+ if (
+ getComputedStyle(document.documentElement)
+ .getPropertyValue(name)
+ .trim() === ""
+ )
+ throw new Error(`Design token ${name} is not defined on :root`);
+ const probe = document.createElement("i");
+ probe.style.color = `var(${name})`;
+ document.body.append(probe);
+ const color = getComputedStyle(probe).color;
+ probe.remove();
+ return color;
+ }, token);
+}
export async function offline(page: Page) {
await page.route("**/studio/session", (r) =>
r.fulfill({
diff --git a/studio/tests/browser/visual-tour.spec.ts b/studio/tests/browser/visual-tour.spec.ts
index 836cac16..e72c304b 100644
--- a/studio/tests/browser/visual-tour.spec.ts
+++ b/studio/tests/browser/visual-tour.spec.ts
@@ -22,7 +22,9 @@ SPDX-License-Identifier: Apache-2.0
// STUDIO_VISUAL_TOUR_COPY when it names a folder) and checked: no sideways
// page scroll, no control cut off by the viewport or its container, no
// control covered by another element, text contrast of at least 4.5:1 (3:1
-// for large text), and a visible focus indicator on Tab.
+// for large text) measured against the page's own dark ground, a visible focus
+// indicator on Tab whose outline reaches 3:1, Manrope rendering the text, and
+// no icon name without a drawing.
// The platform and the local host are mocked; the local authoring endpoints
// and the simulation artifact use the repository's real Python code.
import { selectChoice } from "./support";
@@ -467,6 +469,51 @@ function focusedLooksFocused(overlays: string) {
s.outlineStyle !== "none" &&
parseFloat(s.outlineWidth) >= 1 &&
!/rgba\(\d+, \d+, \d+, 0\)|transparent/.test(s.outlineColor);
+ // An outline ring reaches 3:1 against what is painted behind it (1.4.11).
+ let ringContrast: number | null = null;
+ if (ring) {
+ type Rgba = [number, number, number, number];
+ const parse = (value: string): Rgba | null => {
+ const m = value.match(/rgba?\(([^)]+)\)/);
+ if (!m) return null;
+ const p = m[1]
+ .split(/[ ,/]+/)
+ .filter(Boolean)
+ .map(Number);
+ return [p[0], p[1], p[2], p.length > 3 ? p[3] : 1];
+ };
+ const over = (top: Rgba, below: Rgba): Rgba => {
+ const a = top[3] + below[3] * (1 - top[3]);
+ const mix = (i: number) =>
+ a ? (top[i] * top[3] + below[i] * below[3] * (1 - top[3])) / a : 0;
+ return [mix(0), mix(1), mix(2), a];
+ };
+ const luminance = (c: Rgba) => {
+ const [r, g, b] = c.slice(0, 3).map((v) => {
+ const k = v / 255;
+ return k <= 0.04045 ? k / 12.92 : ((k + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+ };
+ // The ring is drawn outside the box, over the parent's painted layers.
+ const layers: Rgba[] = [];
+ for (let e = active.parentElement; e; e = e.parentElement) {
+ const color = parse(getComputedStyle(e).backgroundColor);
+ if (color && color[3] > 0) {
+ layers.push(color);
+ if (color[3] >= 1) break;
+ }
+ }
+ let back: Rgba = [0, 0, 0, 0];
+ for (const layer of layers.reverse()) back = over(layer, back);
+ const ink = parse(s.outlineColor);
+ if (ink && back[3] > 0) {
+ const [l1, l2] = [luminance(over(ink, back)), luminance(back)].sort(
+ (x, y) => y - x,
+ );
+ ringContrast = (l1 + 0.05) / (l2 + 0.05);
+ }
+ }
const shown =
concealed && (active as HTMLInputElement).labels?.[0]
? (active as HTMLInputElement).labels![0].getBoundingClientRect()
@@ -501,7 +548,14 @@ function focusedLooksFocused(overlays: string) {
else obscured = what;
}
}
- return { name, visible: changed || ring, onScreen, obscured, behindOverlay };
+ return {
+ name,
+ visible: changed || ring,
+ onScreen,
+ obscured,
+ behindOverlay,
+ ringContrast,
+ };
}
/**
@@ -539,6 +593,22 @@ function contrastProblems(): string[] {
const [l1, l2] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (l1 + 0.05) / (l2 + 0.05);
};
+ // The page's own ground: html's background, then body's. Dark pages are
+ // never measured against an assumed white.
+ const problems: string[] = [];
+ const ground = [document.documentElement, document.body]
+ .map((e) => parse(getComputedStyle(e).backgroundColor))
+ .find((c): c is Rgba => !!c && c[3] >= 1);
+ const probe = document.createElement("i");
+ probe.style.color = "var(--bg)";
+ document.body.append(probe);
+ const token = parse(getComputedStyle(probe).color);
+ probe.remove();
+ if (!ground) problems.push("the page background (html, body) is not opaque");
+ else if (!token || ground.some((v, i) => i < 3 && v !== token[i]))
+ problems.push(
+ `the page background rgb(${ground.slice(0, 3).join(", ")}) is not --bg`,
+ );
/** The color painted behind an element, or null when it's an image. */
const backdrop = (start: Element): Rgba | null => {
const layers: Rgba[] = [];
@@ -552,7 +622,7 @@ function contrastProblems(): string[] {
if (color[3] >= 1) break;
}
}
- let result: Rgba = [255, 255, 255, 1];
+ let result: Rgba = ground ?? [0, 0, 0, 1];
for (const layer of layers.reverse()) result = over(layer, result);
return result;
};
@@ -590,7 +660,7 @@ function contrastProblems(): string[] {
.join(", ")}), ${size}px)`;
found.set(key, key);
}
- return [...found.values()];
+ return [...problems, ...found.values()];
}
class Tour {
@@ -604,10 +674,20 @@ class Tour {
readonly scene: string,
) {}
- /** Lets fonts, transitions and deferred blocks settle before a capture. */
- async settle() {
- await this.page.evaluate(async () => {
+ /**
+ * Lets fonts, transitions and deferred blocks settle before a capture.
+ * Resolves false when Studio's own Manrope is not what renders the text.
+ */
+ async settle(): Promise {
+ return this.page.evaluate(async () => {
await document.fonts.ready;
+ const manrope =
+ document.fonts.check("14px Manrope") &&
+ [...document.fonts].some(
+ (face) =>
+ face.family.replace(/["']/g, "") === "Manrope" &&
+ face.status === "loaded",
+ );
const finite = document
.getAnimations()
.filter((a) => a.effect?.getComputedTiming().iterations !== Infinity)
@@ -635,6 +715,7 @@ class Tour {
if (now === last) break;
last = now;
}
+ return manrope;
});
}
@@ -659,9 +740,11 @@ class Tour {
options: { end?: string; tabs?: number } = {},
) => {
const page = this.page;
+ const where = `${this.size.tag} ${name}`;
// Park the pointer on the window's edge so no hover state is captured.
await page.mouse.move(this.size.width - 1, this.size.height - 1);
- await this.settle();
+ if (!(await this.settle()))
+ this.problems.push(`${where}: Manrope did not render the text`);
await this.capture(name);
if (options.end) {
// Page through the main scroller so every part of a long state is seen.
@@ -697,7 +780,6 @@ class Tour {
}
}
const audit = await page.evaluate(auditPage, overlays);
- const where = `${this.size.tag} ${name}`;
if (audit.scrollDown > 1)
this.problems.push(
`${where}: the app shell scrolls down by ${audit.scrollDown}px (${audit.below.join("; ")})`,
@@ -714,6 +796,11 @@ class Tour {
for (const s of audit.covered) this.problems.push(`${where}: ${s}`);
for (const s of await page.evaluate(contrastProblems))
this.problems.push(`${where}: low text contrast ${s}`);
+ // An icon name without a drawing falls back to "workflows" silently.
+ for (const s of await page
+ .locator("weave-icon[data-icon-missing]")
+ .evaluateAll((icons) => icons.map((i) => i.getAttribute("data-icon"))))
+ this.problems.push(`${where}: no icon named "${s}"`);
this.report[name] = { truncated: audit.truncated };
await this.focus(where, options.tabs ?? 3);
};
@@ -749,6 +836,10 @@ class Tour {
this.problems.push(
`${where}: focus on ${focused.name} behind ${focused.behindOverlay}`,
);
+ if (focused.ringContrast !== null && focused.ringContrast < 3)
+ this.problems.push(
+ `${where}: focus ring on ${focused.name} is ${focused.ringContrast.toFixed(2)}:1, under 3:1`,
+ );
}
await before.evaluate((element) => {
if (element instanceof HTMLElement && element !== document.body)
@@ -1364,10 +1455,15 @@ const scenes: Record = {
}),
);
await newWorkflow(page);
- await page.getByRole("button", { name: "Ask Lumi", exact: true }).click();
- const panel = page.getByRole("dialog", { name: "Ask Lumi", exact: true });
+ await page
+ .getByRole("button", { name: "Ask Weave AI", exact: true })
+ .click();
+ const panel = page.getByRole("dialog", {
+ name: "Ask Weave AI",
+ exact: true,
+ });
await panel
- .getByLabel("Message to Lumi")
+ .getByLabel("Message to Weave AI")
.fill("Help me add a review step.");
await panel.getByLabel("Include current source", { exact: true }).check();
await shot("lumi-context", { end: ".modal-panel" });
@@ -1381,7 +1477,7 @@ const scenes: Record = {
.click();
await shot("lumi-review", { end: ".modal-panel" });
await panel
- .getByRole("button", { name: "Lumi settings", exact: true })
+ .getByRole("button", { name: "Weave AI settings", exact: true })
.click();
await expect(panel.getByLabel("Model", { exact: true })).toBeVisible();
await shot("lumi-settings", { end: ".modal-panel" });
diff --git a/studio/tests/browser/lumi.spec.ts b/studio/tests/browser/weave-ai.spec.ts
similarity index 86%
rename from studio/tests/browser/lumi.spec.ts
rename to studio/tests/browser/weave-ai.spec.ts
index 26f7395d..23bb3104 100644
--- a/studio/tests/browser/lumi.spec.ts
+++ b/studio/tests/browser/weave-ai.spec.ts
@@ -41,7 +41,7 @@ for (const viewport of [
])
test.describe(`${viewport.width}x${viewport.height}`, () => {
test.use({ viewport });
- test("Lumi requires context opt-in and a reviewed validated explicit apply", async ({
+ test("Weave AI requires context opt-in and a reviewed validated explicit apply", async ({
page,
}) => {
await connected(page, { capabilities: [...allCapabilities, "lumi.use"] });
@@ -79,10 +79,15 @@ for (const viewport of [
}),
);
await newWorkflow(page);
- await page.getByRole("button", { name: "Ask Lumi", exact: true }).click();
- const panel = page.getByRole("dialog", { name: "Ask Lumi", exact: true });
+ await page
+ .getByRole("button", { name: "Ask Weave AI", exact: true })
+ .click();
+ const panel = page.getByRole("dialog", {
+ name: "Ask Weave AI",
+ exact: true,
+ });
await panel
- .getByLabel("Message to Lumi", { exact: true })
+ .getByLabel("Message to Weave AI", { exact: true })
.fill("Suggest a two-minute wait.");
await panel.getByLabel("Include current source", { exact: true }).check();
await panel
@@ -141,22 +146,24 @@ test("context defaults off, stale proposals cannot replace later edits, and relo
});
});
await newWorkflow(page);
- await page.getByRole("button", { name: "Ask Lumi", exact: true }).click();
- const panel = page.getByRole("dialog", { name: "Ask Lumi", exact: true });
+ await page.getByRole("button", { name: "Ask Weave AI", exact: true }).click();
+ const panel = page.getByRole("dialog", { name: "Ask Weave AI", exact: true });
await expect(
panel.getByLabel("Include current source", { exact: true }),
).not.toBeChecked();
- await panel.getByLabel("Message to Lumi").fill("Suggest a change");
+ await panel.getByLabel("Message to Weave AI").fill("Suggest a change");
await panel
.getByRole("button", { name: "Send message", exact: true })
.click();
await expect(panel).toContainText("Private conversation");
expect(sent.draft).toBeUndefined();
expect(sent.attachments).toEqual([]);
- await panel.getByRole("button", { name: "Close Lumi", exact: true }).click();
+ await panel
+ .getByRole("button", { name: "Close Weave AI", exact: true })
+ .click();
const designer = new DesignerPage(page);
await designer.append("wait");
- await page.getByRole("button", { name: "Ask Lumi", exact: true }).click();
+ await page.getByRole("button", { name: "Ask Weave AI", exact: true }).click();
await panel
.getByRole("button", { name: "Review workflow", exact: true })
.click();
@@ -167,7 +174,7 @@ test("context defaults off, stale proposals cannot replace later edits, and relo
panel.getByRole("button", { name: "Apply to local draft", exact: true }),
).toBeDisabled();
await page.reload();
- await page.getByRole("button", { name: "Ask Lumi", exact: true }).click();
+ await page.getByRole("button", { name: "Ask Weave AI", exact: true }).click();
await expect(panel).not.toContainText("Private conversation");
});
@@ -175,7 +182,7 @@ for (const viewport of [
{ width: 1440, height: 900 },
{ width: 390, height: 700 },
])
- test(`Lumi settings wizard preserves drafts and saves reviewed configuration at ${viewport.width}px`, async ({
+ test(`Weave AI settings wizard preserves drafts and saves reviewed configuration at ${viewport.width}px`, async ({
page,
}, testInfo) => {
await page.setViewportSize(viewport);
@@ -257,10 +264,15 @@ for (const viewport of [
return r.fulfill({ json: original });
});
await newWorkflow(page);
- await page.getByRole("button", { name: "Ask Lumi", exact: true }).click();
- const panel = page.getByRole("dialog", { name: "Ask Lumi", exact: true });
+ await page
+ .getByRole("button", { name: "Ask Weave AI", exact: true })
+ .click();
+ const panel = page.getByRole("dialog", {
+ name: "Ask Weave AI",
+ exact: true,
+ });
await panel
- .getByRole("button", { name: "Lumi settings", exact: true })
+ .getByRole("button", { name: "Weave AI settings", exact: true })
.click();
await expect(
panel.getByRole("button", { name: "New conversation", exact: true }),
@@ -271,7 +283,10 @@ for (const viewport of [
0,
);
await expect(
- panel.getByRole("button", { name: "Save Lumi settings", exact: true }),
+ panel.getByRole("button", {
+ name: "Save Weave AI settings",
+ exact: true,
+ }),
).toHaveCount(0);
await panel
.getByRole("button", { name: "Continue to connection", exact: true })
@@ -297,7 +312,10 @@ for (const viewport of [
.getByRole("button", { name: "Review settings", exact: true })
.click();
await expect(
- panel.getByRole("heading", { name: "Review Lumi settings", exact: true }),
+ panel.getByRole("heading", {
+ name: "Review Weave AI settings",
+ exact: true,
+ }),
).toBeFocused();
await expect(panel.locator(".settings-summary")).toContainText(
"configured-model",
@@ -316,10 +334,10 @@ for (const viewport of [
true,
);
await panel
- .getByRole("button", { name: "Save Lumi settings", exact: true })
+ .getByRole("button", { name: "Save Weave AI settings", exact: true })
.click();
await expect(
- panel.getByRole("button", { name: "Lumi settings", exact: true }),
+ panel.getByRole("button", { name: "Weave AI settings", exact: true }),
).toBeHidden();
await expect(
panel.getByRole("button", { name: "New conversation", exact: true }),
@@ -328,9 +346,9 @@ for (const viewport of [
panel.getByRole("button", { name: "Reload settings", exact: true }),
).toBeDisabled();
finishSave();
- await expect(panel.getByLabel("Message to Lumi")).toBeVisible();
+ await expect(panel.getByLabel("Message to Weave AI")).toBeVisible();
await expect(
- panel.getByRole("button", { name: "Lumi settings", exact: true }),
+ panel.getByRole("button", { name: "Weave AI settings", exact: true }),
).toBeVisible();
await expect(
panel.getByRole("button", { name: "New conversation", exact: true }),
@@ -372,9 +390,9 @@ test("non-workflow proposals are validated before saving a reviewed file", async
r.fulfill({ json: { validationOk: true, errorCount: 0, diagnostics: [] } }),
);
await newWorkflow(page);
- await page.getByRole("button", { name: "Ask Lumi", exact: true }).click();
- const panel = page.getByRole("dialog", { name: "Ask Lumi", exact: true });
- await panel.getByLabel("Message to Lumi").fill("Propose a policy");
+ await page.getByRole("button", { name: "Ask Weave AI", exact: true }).click();
+ const panel = page.getByRole("dialog", { name: "Ask Weave AI", exact: true });
+ await panel.getByLabel("Message to Weave AI").fill("Propose a policy");
await panel
.getByRole("button", { name: "Send message", exact: true })
.click();
diff --git a/studio/tests/browser/wizard-footer.spec.ts b/studio/tests/browser/wizard-footer.spec.ts
index 18f3704c..efe65058 100644
--- a/studio/tests/browser/wizard-footer.spec.ts
+++ b/studio/tests/browser/wizard-footer.spec.ts
@@ -20,6 +20,7 @@ SPDX-License-Identifier: Apache-2.0
// below or through it.
import { test, expect, Page } from "@playwright/test";
import { acmeDiscovery, platformHost } from "./platform-host";
+import { tokenColor } from "./support";
async function reviewStep(page: Page) {
await platformHost(page, {
@@ -66,6 +67,7 @@ for (const viewport of [
(element) => element.scrollHeight - element.clientHeight,
);
expect(overflow).toBeGreaterThan(40);
+ const surface = await tokenColor(page, "--surface");
for (const position of [0, Math.floor(overflow / 2), overflow]) {
await scroller.evaluate(
(element, top) => (element.scrollTop = top),
@@ -78,7 +80,7 @@ for (const viewport of [
state.viewport - state.bottom,
`scrolled ${position}`,
).toBeLessThanOrEqual(1);
- expect(state.background).toBe("rgb(255, 255, 255)");
+ expect(state.background).toBe(surface);
if (position < overflow)
expect(state.corners, `corners at ${position}`).toBe(true);
}
diff --git a/studio/tests/design-tokens.test.ts b/studio/tests/design-tokens.test.ts
index 59d727ab..af5e9084 100644
--- a/studio/tests/design-tokens.test.ts
+++ b/studio/tests/design-tokens.test.ts
@@ -17,13 +17,74 @@ SPDX-License-Identifier: Apache-2.0
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { fileURLToPath } from "node:url";
-import { join } from "node:path";
+import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
const source = (path: string) =>
readFileSync(new URL(`../src/${path}`, import.meta.url), "utf8");
+const appDir = fileURLToPath(new URL("../src/app", import.meta.url));
+/** Every component stylesheet, template and script under src/app. */
+function appFiles(dir = appDir) {
+ const found: string[] = [];
+ for (const name of readdirSync(dir)) {
+ const path = join(dir, name);
+ if (statSync(path).isDirectory()) found.push(...appFiles(path));
+ else if (/\.(ts|css|html)$/.test(name)) found.push(path);
+ }
+ return found;
+}
const styles = source("styles.css");
+/**
+ * The string literals that start at `from`: one literal, or the literals of an
+ * array (the shape of a component's `styles`).
+ */
+function literals(text: string, from: number): string[] {
+ const found: string[] = [];
+ let i = from;
+ const skip = () => {
+ while (/[\s,]/.test(text[i] ?? "")) i++;
+ };
+ const read = () => {
+ const quote = text[i++];
+ let value = "";
+ while (i < text.length && text[i] !== quote) {
+ if (text[i] === "\\") i++;
+ value += text[i++];
+ }
+ i++;
+ return value;
+ };
+ skip();
+ if (/[`'"]/.test(text[i] ?? "")) found.push(read());
+ else if (text[i] === "[") {
+ i++;
+ for (skip(); i < text.length && text[i] !== "]"; skip()) {
+ if (/[`'"]/.test(text[i])) found.push(read());
+ else i++;
+ }
+ }
+ return found;
+}
+/**
+ * The CSS a component file carries: a .css file whole; a .ts file's `styles`
+ * blocks and its `const …Styles = \`…\`` stylesheets.
+ */
+function componentCss(file: string): string[] {
+ const text = readFileSync(file, "utf8");
+ if (file.endsWith(".css")) return [text];
+ if (!file.endsWith(".ts")) return [];
+ return [...text.matchAll(/\bstyles\s*:|\bconst\s+\w*[sS]tyles\s*=/g)].flatMap(
+ (m) => literals(text, m.index + m[0].length),
+ );
+}
+/** Every declaration in the component styles under src/app, by file. */
+const appDeclarations = appFiles().flatMap((file) =>
+ componentCss(file).flatMap((css) =>
+ declarations(css).map((d) => ({ file, ...d })),
+ ),
+);
+
interface Declaration {
/** Enclosing at-rules, outermost first. */
media: string;
@@ -84,131 +145,339 @@ const token = (name: string) => {
if (!value) throw Error(`missing token ${name}`);
return value;
};
+/** Contrast of two tokens that hold #rrggbb colors. */
+const ratio = (a: string, b: string) => contrast(token(a), token(b));
+/** The color-scheme :root declares (it is not a custom property). */
+const colorScheme = () =>
+ all.find((d) => d.selector === ":root" && d.property === "color-scheme")
+ ?.value;
describe("design tokens", () => {
- it("keeps the brand and declares the plan's tokens", () => {
+ const surfaces = [
+ "--bg",
+ "--sunken",
+ "--surface",
+ "--raised",
+ "--hover",
+ "--selected",
+ ];
+ const tones = ["success", "info", "warning", "danger"];
+
+ it("declares the dark theme and its anchor values", () => {
+ expect(colorScheme()).toBe("dark");
expect(Object.fromEntries(root)).toMatchObject({
- "--forest": "#173d34",
- "--jade": "#367d68",
- "--mint": "#a1d1b9",
- "--mist": "#eef4f0",
- "--gold": "#b88322",
- "--field-border": "#738c80",
- "--flow-line": "#5f8a76",
- "--disabled-bg": "#eef2ef",
- "--disabled-ink": "#5c706a",
- "--focus": "#2c6a57",
- "--focus-on-dark": "#a1d1b9",
- "--live": "#b88322",
+ "--bg": "#10110f",
+ "--surface": "#1a1b17",
+ "--text": "#f3f1eb",
+ "--muted": "#bfb8ab",
+ "--accent": "#ffb34a",
+ "--on-accent": "#10110f",
+ "--field-border": "#767672",
+ "--flow-line": "#767672",
+ "--focus": "#ffb34a",
+ "--live": "#ffb34a",
"--control-md": "36px",
"--control-touch": "44px",
"--radius-lg": "12px",
});
- for (const name of [
- "--type-caption",
- "--type-small",
- "--type-body",
- "--type-label",
- "--type-button",
- "--type-title-sm",
- "--type-title-md",
- "--type-title-lg",
- "--type-display",
- "--type-mono",
- "--shadow-1",
- "--shadow-2",
- "--shadow-3",
- "--info-ink",
- "--info-bg",
- "--info-bd",
- "--panel-w",
+ });
+
+ it("declares every token lanes build on (contract C1)", () => {
+ const names = [
+ ...["--bg", "--sunken", "--surface", "--raised", "--hover", "--selected"],
+ ...[
+ "--accent-soft",
+ "--disabled-bg",
+ "--canvas",
+ "--canvas-dot",
+ "--scrim",
+ ],
+ ...["--text", "--muted", "--subtle", "--link", "--disabled-ink"],
+ ...[
+ "--accent",
+ "--accent-hover",
+ "--accent-press",
+ "--on-accent",
+ "--accent-glow",
+ ],
+ ...[
+ "--line",
+ "--border",
+ "--border-hover",
+ "--field-border",
+ "--field-border-hover",
+ ],
+ ...tones.flatMap((t) => [
+ `--${t}-ink`,
+ `--${t}-bg`,
+ `--${t}-bd`,
+ `--${t}`,
+ ]),
+ ...["--neutral-ink", "--neutral-bg", "--neutral-bd", "--danger-hover"],
+ ...["--focus", "--selection"],
+ ...["--flow-line", "--flow-line-dim", "--node-bg", "--node-border"],
+ ...["--live", "--live-glow", "--marquee-bg"],
+ ...[
+ "--series-1",
+ "--series-2",
+ "--series-3",
+ "--series-4",
+ "--series-other",
+ ],
+ ...["yellow", "blue", "green", "pink", "purple", "gray"].flatMap((n) => [
+ `--note-${n}-bg`,
+ `--note-${n}-bd`,
+ ]),
+ ...["--syntax-keyword", "--syntax-string", "--syntax-number"],
+ ...["--diff-add-bg", "--diff-add-ink", "--diff-del-bg", "--diff-del-ink"],
+ "--skeleton",
+ ...["--font-sans", "--font-mono"],
+ ...[
+ "caption",
+ "small",
+ "body",
+ "label",
+ "button",
+ "title-sm",
+ "title-md",
+ ].map((t) => `--type-${t}`),
+ ...["--type-title-lg", "--type-display", "--type-mono"],
+ ...["--shadow-1", "--shadow-2", "--shadow-3"],
+ ...["--ease", "--ease-move", "--ease-exit"],
+ ...["--dur-fast", "--dur", "--dur-panel", "--dur-expressive"],
+ ...["--control-sm", "--control-md", "--control-touch", "--panel-w"],
+ ];
+ expect(names.filter((name) => !root.has(name))).toEqual([]);
+ expect(token("--font-sans")).toMatch(/^"Manrope", system-ui/);
+ expect(token("--type-title-lg")).toMatch(/^600 24px\/32px /);
+ expect(token("--type-display")).toMatch(/^500 32px\/40px /);
+ expect(token("--dur-expressive")).toBe("1200ms");
+ });
+
+ it("drops the light-theme brand tokens everywhere under studio/src", () => {
+ const removed =
+ /--(forest|forest-hover|forest-press|forest-active|jade|jade-strong|mint|mist|gold|gold-soft|on-dark|on-dark-muted|on-dark-accent|focus-on-dark)\b/;
+ for (const file of [
+ fileURLToPath(new URL("../src/styles.css", import.meta.url)),
+ ...appFiles(),
])
- expect(root.has(name), name).toBe(true);
+ expect(readFileSync(file, "utf8"), file).not.toMatch(removed);
+ expect(styles).not.toContain(".forest-surface");
});
- it("has no raw hex color outside :root and forced-colors", () => {
+ it("writes colors only in :root: no literal in styles.css or src/app", () => {
+ const literal = /#[0-9a-f]{3,8}\b|\brgba?\(|\bhsla?\(/i;
const raw = all.filter(
(d) =>
d.selector !== ":root" &&
!d.media.includes("forced-colors") &&
- /#[0-9a-f]{3,8}\b/i.test(d.value),
+ literal.test(d.value),
);
expect(
raw.map((d) => `${d.selector} { ${d.property}: ${d.value} }`),
).toEqual([]);
+ for (const file of appFiles()) {
+ const text = readFileSync(file, "utf8").replace(/href="#[^"]*"/g, "");
+ const lines = text.split("\n").filter((line) => literal.test(line));
+ expect(lines, file).toEqual([]);
+ }
});
- it("uses only the four font weights Avenir Next has", () => {
- const weights = all
- .filter((d) => d.property === "font-weight")
- .map((d) => d.value);
- for (const weight of weights)
- expect(["400", "500", "600", "700", "inherit"]).toContain(weight);
- // Shorthands and type tokens follow the same rule.
- for (const d of all.filter((d) => d.property.startsWith("--type-")))
- expect(d.value).toMatch(/^(400|500|600|700) /);
+ it("never redefines a theme token outside :root (one value set per theme)", () => {
+ const local = all.filter(
+ (d) =>
+ d.selector !== ":root" &&
+ d.property.startsWith("--") &&
+ root.has(d.property),
+ );
+ expect(local.map((d) => `${d.selector} { ${d.property} }`)).toEqual([]);
+ for (const file of appFiles())
+ for (const [, name] of readFileSync(file, "utf8").matchAll(
+ /^\s*(--[a-z0-9-]+)\s*:/gm,
+ ))
+ expect(root.has(name), `${file} redefines ${name}`).toBe(false);
});
- it("sets no text below 12px outside the canvas (wave 3 owns it)", () => {
- const small = all.filter((d) => {
- const size = /^([\d.]+)px$/.exec(d.value);
- return (
- d.property === "font-size" &&
- !!size &&
- Number(size[1]) < 12 &&
- !/graph-flow|node-|canvas-chip/.test(d.selector)
+ it("reads the component styles under src/app (the rules below scan them)", () => {
+ const files = new Set(appDeclarations.map((d) => d.file));
+ // A .css file, a `styles: [...]` array, a `styles: \`...\`` literal and a
+ // shared `const …Styles` stylesheet: all four shapes must be found.
+ for (const name of [
+ "operations/operations-view.css",
+ "workspace-picker.ts",
+ "integrations/ai-setup-wizard.ts",
+ "integrations/http-action-styles.ts",
+ ])
+ expect([...files].some((f) => f.replace(/\\/g, "/").endsWith(name))).toBe(
+ true,
);
- });
- expect(small.map((d) => `${d.selector}: ${d.value}`)).toEqual([]);
+ expect(appDeclarations.length).toBeGreaterThan(1500);
});
- it("meets the contrast the plan measured", () => {
- const white =
- token("--surface") === "#fff" ? "#ffffff" : token("--surface");
- // Field borders and canvas lines: 3:1 against what they sit on (1.4.11).
- for (const surface of [white, token("--bg"), token("--mist")])
- expect(contrast(token("--field-border"), surface)).toBeGreaterThanOrEqual(
- 3,
- );
- expect(contrast(token("--flow-line"), token("--canvas"))).toBeGreaterThan(
- 3.7,
+ it("uses only the weights Studio uses", () => {
+ const weights = ["400", "500", "600", "700"];
+ const faces = all.filter((d) => d.selector !== "@font-face");
+ for (const d of faces.filter((d) => d.property === "font-weight"))
+ expect([...weights, "inherit"]).toContain(d.value);
+ // Shorthands and type tokens follow the same rule.
+ for (const d of faces.filter((d) => d.property.startsWith("--type-")))
+ expect(d.value).toMatch(/^(400|500|600|700) /);
+ // Component styles follow it too: a weight off the scale (650, 550) would
+ // fall between the variable font's instances.
+ const component = appDeclarations.filter(
+ (d) => d.property === "font-weight",
);
- // Disabled text stays legible.
+ expect(component.length).toBeGreaterThan(50);
expect(
- contrast(token("--disabled-ink"), token("--disabled-bg")),
- ).toBeGreaterThanOrEqual(4.5);
- // The focus ring is visible on every light surface, mint on forest.
- for (const surface of [white, token("--bg"), token("--mist")])
- expect(contrast(token("--focus"), surface)).toBeGreaterThanOrEqual(3);
- expect(
- contrast(token("--focus-on-dark"), token("--forest")),
- ).toBeGreaterThanOrEqual(7);
+ component
+ .filter((d) => ![...weights, "inherit"].includes(d.value))
+ .map((d) => `${d.file}: ${d.selector} { font-weight: ${d.value} }`),
+ ).toEqual([]);
+ const shorthand = [...faces, ...appDeclarations].filter(
+ (d) => d.property === "font" && /^\d{3}\s/.test(d.value),
+ );
expect(
- contrast(token("--focus-on-dark"), token("--forest-active")),
- ).toBeGreaterThanOrEqual(3);
- // Links on mist, text on a selected row.
- expect(contrast(token("--link"), token("--mist"))).toBeGreaterThan(4.5);
- expect(contrast(token("--muted"), token("--selected"))).toBeGreaterThan(
+ shorthand
+ .filter((d) => !weights.includes(d.value.slice(0, 3)))
+ .map((d) => `${d.selector} { font: ${d.value} }`),
+ ).toEqual([]);
+ // The variable font covers the whole range it declares.
+ for (const d of all.filter(
+ (d) => d.selector === "@font-face" && d.property === "font-weight",
+ ))
+ expect(d.value).toBe("200 800");
+ });
+
+ it("sets no text below 12px outside the canvas (wave 3 owns it)", () => {
+ const px = (d: { property: string; value: string }) => {
+ const size =
+ d.property === "font-size"
+ ? /^([\d.]+)px$/.exec(d.value)
+ : d.property === "font"
+ ? /^(?:\d{3}\s+)?([\d.]+)px\b/.exec(d.value)
+ : null;
+ return size ? Number(size[1]) : Infinity;
+ };
+ const canvas = (selector: string) =>
+ /graph-flow|node-|canvas-chip/.test(selector);
+ const small = all.filter((d) => px(d) < 12 && !canvas(d.selector));
+ expect(small.map((d) => `${d.selector}: ${d.value}`)).toEqual([]);
+ // Component styles follow the rule too. These five 11px declarations
+ // predate the rule and are product sizes this change does not own: the
+ // list may only shrink, so a new one fails and a fixed one must leave it.
+ const known = [
+ "forms/ui/schema-designer.ts: .sd-json pre",
+ "operations/operations-view.css: .eyebrow",
+ "task-form.ts: .schema-map-row button, .schema-remove",
+ "task-form.ts: .schema-null",
+ "templates/new-menu.ts: .new-menu-list small",
+ ];
+ const name = (d: { file: string; selector: string }) =>
+ `${relative(appDir, d.file).replace(/\\/g, "/")}: ${d.selector.replace(/\s+/g, " ")}`;
+ const component = appDeclarations
+ .filter((d) => px(d) < 12 && !canvas(d.selector))
+ .map(name);
+ expect(component.filter((n) => !known.includes(n))).toEqual([]);
+ expect(known.filter((n) => !component.includes(n))).toEqual([]);
+ });
+
+ it("keeps text at 4.5:1 and boundaries at 3:1 on every surface", () => {
+ for (const surface of surfaces) {
+ for (const ink of ["--text", "--muted", "--subtle", "--link"])
+ expect(
+ ratio(ink, surface),
+ `${ink} on ${surface}`,
+ ).toBeGreaterThanOrEqual(4.5);
+ for (const line of ["--field-border", "--focus"])
+ expect(
+ ratio(line, surface),
+ `${line} on ${surface}`,
+ ).toBeGreaterThanOrEqual(3);
+ }
+ expect(ratio("--disabled-ink", "--disabled-bg")).toBeGreaterThanOrEqual(
4.5,
);
- expect(
- contrast(token("--on-dark-muted"), token("--forest-active")),
- ).toBeGreaterThan(4.5);
+ });
+
+ it("draws the canvas and charts at 3:1", () => {
+ expect(ratio("--flow-line", "--canvas")).toBeGreaterThan(3.7);
+ expect(ratio("--node-border", "--canvas")).toBeGreaterThan(3.7);
+ expect(ratio("--flow-line-dim", "--canvas")).toBeGreaterThanOrEqual(3);
+ for (const series of [
+ "--series-1",
+ "--series-2",
+ "--series-3",
+ "--series-4",
+ "--series-other",
+ ])
+ expect(ratio(series, "--surface"), series).toBeGreaterThanOrEqual(3);
+ });
+
+ it("puts 7:1 text on every solid fill", () => {
+ for (const fill of [
+ "--accent",
+ "--accent-hover",
+ "--accent-press",
+ "--danger-hover",
+ ...tones.map((t) => `--${t}`),
+ ])
+ expect(ratio("--on-accent", fill), fill).toBeGreaterThanOrEqual(7);
});
it("keeps every status tone readable and visible on a selected row", () => {
- for (const tone of ["success", "info", "warning", "danger", "neutral"]) {
- const ink = token(`--${tone}-ink`);
- const bg = token(`--${tone}-bg`);
- const border = token(`--${tone}-bd`);
- expect(contrast(ink, bg), `${tone} ink`).toBeGreaterThanOrEqual(6);
- // A pill on a selected row keeps an edge (C15 in the plan).
+ for (const tone of [...tones, "neutral"]) {
+ expect(
+ ratio(`--${tone}-ink`, `--${tone}-bg`),
+ `${tone} ink`,
+ ).toBeGreaterThanOrEqual(6);
+ for (const surface of ["--surface", "--raised", "--selected"])
+ expect(
+ ratio(`--${tone}-ink`, surface),
+ `${tone} on ${surface}`,
+ ).toBeGreaterThanOrEqual(4.5);
+ // A pill on a selected row keeps an edge.
expect(
- contrast(border, token("--selected")),
+ ratio(`--${tone}-bd`, "--selected"),
`${tone} border`,
).toBeGreaterThanOrEqual(1.3);
}
- expect(contrast("#ffffff", token("--danger"))).toBeGreaterThan(7);
+ });
+
+ it("keeps notes, code and diffs readable", () => {
+ for (const note of ["yellow", "blue", "green", "pink", "purple", "gray"])
+ expect(ratio("--text", `--note-${note}-bg`), note).toBeGreaterThanOrEqual(
+ 4.5,
+ );
+ for (const syntax of [
+ "--syntax-keyword",
+ "--syntax-string",
+ "--syntax-number",
+ ])
+ expect(ratio(syntax, "--sunken"), syntax).toBeGreaterThanOrEqual(4.5);
+ expect(ratio("--diff-add-ink", "--diff-add-bg")).toBeGreaterThanOrEqual(6);
+ expect(ratio("--diff-del-ink", "--diff-del-bg")).toBeGreaterThanOrEqual(6);
+ });
+
+ it("draws every step kind alike, the canvas dots in --canvas-dot, and a disabled segment at 7:1", () => {
+ // Spec §2.1: no role colors. A Human task differs by icon and shape only.
+ const human = /\.human(?![\w-])/;
+ expect(all.filter((d) => human.test(d.selector))).toEqual([]);
+ expect(appDeclarations.filter((d) => human.test(d.selector))).toEqual([]);
+ // Spec §2.2: the dot grid is its own decorative token, not a border.
+ expect(
+ all.find(
+ (d) => d.selector === ".canvas" && d.property === "background-image",
+ )?.value,
+ ).toContain("var(--canvas-dot)");
+ // A checked segment of a disabled mode switch keeps 7:1 text on its fill.
+ const segment = appDeclarations.find(
+ (d) =>
+ d.file.endsWith("property-grid.css") &&
+ d.selector === '.mode-switch button:disabled[aria-checked="true"]' &&
+ d.property === "background",
+ );
+ expect(segment?.value).toMatch(/^var\(--muted\)/);
+ expect(ratio("--on-accent", "--muted")).toBeGreaterThanOrEqual(7);
});
it("styles disabled buttons with tokens, never opacity", () => {
@@ -220,16 +489,7 @@ describe("design tokens", () => {
disabled.filter((d) => d.property === "opacity" && d.value !== "1"),
).toEqual([]);
// Component styles follow the same rule.
- const files: string[] = [];
- const walk = (dir: string) => {
- for (const name of readdirSync(dir)) {
- const path = join(dir, name);
- if (statSync(path).isDirectory()) walk(path);
- else if (/\.(ts|css)$/.test(name)) files.push(path);
- }
- };
- walk(fileURLToPath(new URL("../src/app", import.meta.url)));
- for (const file of files) {
+ for (const file of appFiles().filter((f) => /\.(ts|css)$/.test(f))) {
const text = readFileSync(file, "utf8");
for (const rule of text.matchAll(/([^{}]*)\{([^{}]*)\}/g))
if (/:disabled|aria-disabled|\.disabled\b/.test(rule[1]))
@@ -239,7 +499,7 @@ describe("design tokens", () => {
}
});
- it("draws one focus ring: 2px in --focus, mint in the sidebar", () => {
+ it("draws one 2px amber focus ring on every surface", () => {
const ring = all.find(
(d) =>
/^:is\(\s*button,\s*a,/.test(d.selector) &&
@@ -247,11 +507,8 @@ describe("design tokens", () => {
d.property === "outline",
);
expect(ring?.value).toBe("2px solid var(--focus)");
- const dark = all.find(
- (d) =>
- d.selector.startsWith(":is(.sidebar") && d.property === "outline-color",
- );
- expect(dark?.value).toBe("var(--focus-on-dark)");
+ // Every surface is dark: no sidebar or toast override remains.
+ expect(all.filter((d) => d.property === "outline-color")).toEqual([]);
// No component keeps its own 3px ring.
for (const file of [
"app/home-dashboard.css",
@@ -264,6 +521,16 @@ describe("design tokens", () => {
expect(source(file), file).not.toMatch(/outline:\s*3px/);
});
+ it("selects text in translucent amber with paper text", () => {
+ const selection = all.filter((d) => d.selector === "::selection");
+ expect(
+ Object.fromEntries(selection.map((d) => [d.property, d.value])),
+ ).toEqual({
+ background: "var(--selection)",
+ color: "var(--text)",
+ });
+ });
+
it("writes group labels in sentence case", () => {
for (const file of [
"app/designer/step-picker.ts",
diff --git a/studio/tests/icon.test.ts b/studio/tests/icon.test.ts
index 8eb017de..3dde9b3c 100644
--- a/studio/tests/icon.test.ts
+++ b/studio/tests/icon.test.ts
@@ -16,54 +16,138 @@ Author: Firefly Software Foundation
SPDX-License-Identifier: Apache-2.0
*/
import "@angular/compiler";
+import { readFileSync, readdirSync, statSync } from "node:fs";
+import { join, relative } from "node:path";
+import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
-import { iconPaths, iconSize, iconStrokes, ringedIcons } from "../src/app/icon";
+import {
+ iconDrawing,
+ iconNames,
+ iconSize,
+ iconStrokes,
+ isIconName,
+} from "../src/app/icon";
+import { iconNodes, lucideNames } from "../src/app/icon-data";
import { kinds } from "../src/app/model";
+import { iconMap, renderIconData } from "../scripts/build-icons.mjs";
+
+const app = fileURLToPath(new URL("../src/app", import.meta.url));
+
+function sources(dir = app): string[] {
+ return readdirSync(dir).flatMap((name) => {
+ const path = join(dir, name);
+ if (statSync(path).isDirectory()) return sources(path);
+ return /\.(ts|html)$/.test(name) ? [path] : [];
+ });
+}
+
+/** Every icon name a template or the code asks for, with where it is asked. */
+function requestedIcons(): Map {
+ const found = new Map();
+ const add = (name: string, where: string) => {
+ if (!found.has(name)) found.set(name, where);
+ };
+ for (const file of sources()) {
+ const text = readFileSync(file, "utf8");
+ const where = relative(app, file);
+ for (const [tag] of text.matchAll(//g)) {
+ for (const [, name] of tag.matchAll(/\sname="([^"]+)"/g))
+ add(name, where);
+ // In a binding, the names are the literals a ternary or ?? can return.
+ for (const [, binding] of tag.matchAll(/\[name\]="([^"]+)"/g))
+ for (const [, name] of binding.matchAll(/(?:^|[?:])\s*'([^']+)'/g))
+ add(name, where);
+ }
+ // Inputs and data that name an icon: icon="…", icon: "…", input("…").
+ for (const [, name] of text.matchAll(
+ /\bicon(?:="|:\s*"|\s*=\s*input\(")([A-Za-z]+)"/g,
+ ))
+ add(name, where);
+ // Getters and maps named for icons.
+ for (const [, body] of text.matchAll(
+ /\bget \w*Icon\(\)\s*\{([\s\S]*?)\n {2}\}/g,
+ ))
+ for (const [, name] of body.matchAll(/return "([A-Za-z]+)"/g))
+ add(name, where);
+ for (const [, body] of text.matchAll(
+ /\bconst icons\b[^=]*=\s*\{([\s\S]*?)\};/g,
+ ))
+ for (const [, name] of body.matchAll(/:\s*"([A-Za-z]+)"/g))
+ add(name, where);
+ }
+ // Sidebar items draw the icon named after their view.
+ const shell = readFileSync(join(app, "app.ts"), "utf8");
+ const nav = /\bnav: \{ id: View; label: string \}\[\] = \[([\s\S]*?)\];/.exec(
+ shell,
+ );
+ for (const [, id] of nav![1].matchAll(/id: "([A-Za-z]+)"/g))
+ add(id, "app.ts nav");
+ return found;
+}
describe("icons", () => {
- it("draws every meaning differently", () => {
- const drawings = new Map();
- for (const [name, path] of Object.entries(iconPaths)) {
- // A ring is part of the drawing.
- const drawing = `${path}|${ringedIcons.has(name)}`;
- expect(drawings.get(drawing), `${name} reuses a drawing`).toBeUndefined();
- drawings.set(drawing, name);
+ it("draws the 94 mapped names from Lucide", () => {
+ expect(iconNames).toHaveLength(94);
+ expect(lucideNames).toEqual(iconMap);
+ for (const name of iconNames)
+ expect(iconNodes[name].length, name).toBeGreaterThan(0);
+ });
+
+ it("matches a fresh generation from lucide-static byte for byte", async () => {
+ // A Windows checkout may turn LF into CRLF; the generator writes LF.
+ const committed = readFileSync(
+ new URL("../src/app/icon-data.ts", import.meta.url),
+ "utf8",
+ ).replace(/\r\n/g, "\n");
+ expect(await renderIconData()).toBe(committed);
+ });
+
+ it("gives every Weave name its own Lucide icon", () => {
+ const owner = new Map();
+ for (const [weave, lucide] of Object.entries(lucideNames)) {
+ expect(owner.get(lucide), `${weave} reuses ${lucide}`).toBeUndefined();
+ owner.set(lucide, weave);
}
});
- it("has the icons the plan adds and fixes", () => {
- for (const name of [
- "upload",
- "workspace",
+ it("has an icon for every step kind, with the brand's mapping", () => {
+ for (const kind of kinds) expect(isIconName(kind), kind).toBe(true);
+ expect(lucideNames).toMatchObject({
+ wait: "hourglass",
+ signal: "radio-tower",
+ action: "zap",
+ email: "mail",
+ });
+ });
+
+ it("has an icon for every name a template or the code uses", () => {
+ const requested = requestedIcons();
+ // The scan itself must keep finding names in each place it reads.
+ for (const sentinel of [
+ "plus",
+ "template",
+ "assistant",
"more",
- "help",
+ "chevron",
"failCircle",
- "settings",
- "signal",
"humanTask",
+ "connections",
])
- expect(iconPaths[name], name).toBeTruthy();
- // Wait for signal no longer borrows the email envelope.
- expect(iconPaths["signal"]).not.toBe(iconPaths["email"]);
- // Import (upload) and Export (download) point different ways.
- expect(iconPaths["upload"]).not.toBe(iconPaths["download"]);
- // The settings gear has its own outline, not a ring with spokes.
- expect(ringedIcons.has("settings")).toBe(false);
- expect(iconPaths["settings"]).toMatch(/^M12 15a3 3 0 1 0 0-6/);
- // The person's head is a whole circle (two half arcs), not half of one.
- expect(iconPaths["humanTask"]).toMatch(
- /^M12 2a3 3 0 1 0 0 6 3 3 0 0 0 0-6z/,
- );
- for (const name of ["help", "failCircle", "runs", "wait"])
- expect(ringedIcons.has(name), name).toBe(true);
+ expect(requested.has(sentinel), sentinel).toBe(true);
+ const unknown = [...requested]
+ .filter(([name]) => !isIconName(name))
+ .map(([name, where]) => `${name} (${where})`);
+ expect(unknown).toEqual([]);
});
- it("has an icon for every step kind", () => {
- for (const kind of kinds) expect(iconPaths[kind], kind).toBeTruthy();
+ it("draws workflows for an unknown name at run time", () => {
+ expect(isIconName("no-such-icon")).toBe(false);
+ expect(isIconName("toString")).toBe(false);
+ expect(iconDrawing("no-such-icon")).toBe(iconNodes.workflows);
});
- it("comes in two sizes with their own stroke", () => {
- expect(iconStrokes).toEqual({ 16: 1.5, 20: 1.75 });
+ it("strokes 1.5px at both sizes", () => {
+ expect(iconStrokes).toEqual({ 16: 1.5, 20: 1.5 });
expect(iconSize(16)).toBe(16);
expect(iconSize("16")).toBe(16);
expect(iconSize("20")).toBe(20);
diff --git a/studio/tests/weave-ai-naming.test.ts b/studio/tests/weave-ai-naming.test.ts
new file mode 100644
index 00000000..2afb31ec
--- /dev/null
+++ b/studio/tests/weave-ai-naming.test.ts
@@ -0,0 +1,71 @@
+/*
+Copyright 2026 Firefly Software Foundation.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+Author: Firefly Software Foundation
+SPDX-License-Identifier: Apache-2.0
+*/
+import { readFileSync, readdirSync, statSync } from "node:fs";
+import { join, relative } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+// Program glossary C7: user-facing text says "Weave AI"; code identifiers, API
+// paths, capabilities, roles and storage names keep "lumi".
+const src = fileURLToPath(new URL("../src", import.meta.url));
+const read = (path: string) => readFileSync(join(src, path), "utf8");
+
+function sources(dir = src): string[] {
+ return readdirSync(dir).flatMap((name) => {
+ const path = join(dir, name);
+ if (statSync(path).isDirectory()) return sources(path);
+ return /\.(ts|html|css)$/.test(name) ? [path] : [];
+ });
+}
+
+describe("Weave AI naming", () => {
+ it("never says Lumi in Studio's source", () => {
+ const hits = sources().flatMap((file) =>
+ readFileSync(file, "utf8")
+ .split(/\r?\n/)
+ .flatMap((line, index) =>
+ /\bLumi\b/.test(line)
+ ? [`${relative(src, file)}:${index + 1}: ${line.trim()}`]
+ : [],
+ ),
+ );
+ expect(hits).toEqual([]);
+ });
+
+ it("names the assistant Weave AI in the shell, panel and settings", () => {
+ expect(read("app/app.html")).toMatch(
+ /Ask Weave AI<\/span>/,
+ );
+ const panel = read("app/lumi/lumi-panel.ts");
+ expect(panel).toContain('heading="Ask Weave AI"');
+ expect(panel).toContain('closeLabel="Close Weave AI"');
+ expect(panel).toContain('aria-label="Message to Weave AI"');
+ const settings = read("app/settings/settings-page.ts");
+ expect(settings).toContain("
AI models
");
+ expect(settings).not.toContain("AI setup");
+ });
+
+ it("keeps the lumi identifiers the API, roles and storage use", () => {
+ expect(read("app/lumi/lumi-panel.ts")).toContain('host.can("lumi.manage")');
+ const shell = read("app/app.ts");
+ expect(shell).toContain('{ value: "lumi_user", label: "Weave AI user" }');
+ expect(shell).toContain(
+ '{ value: "lumi_manager", label: "Weave AI manager" }',
+ );
+ });
+});
diff --git a/tests/unit/cli/test_graph.py b/tests/unit/cli/test_graph.py
index d9793a96..6c448fd1 100644
--- a/tests/unit/cli/test_graph.py
+++ b/tests/unit/cli/test_graph.py
@@ -175,3 +175,14 @@ def test_svg_fits_wide_names_without_losing_full_hover_labels():
half_width = len(label.text) * 13 / 2
center = float(label.attrib["x"])
assert 24 <= center - half_width <= center + half_width <= width - 24
+
+
+def test_svg_export_carries_no_mascot(artifact):
+ from firefly_weave.compiler.api import import_artifact
+ from firefly_weave.sdk.visualization import render_graph
+
+ svg = render_graph(import_artifact(artifact.read_bytes()), "svg")
+ assert "lumi" not in svg.lower()
+ tree = ET.fromstring(svg)
+ assert tree.find(".//{*}g[@aria-label]") is None
+ assert tree.find(".//{*}radialGradient") is None
diff --git a/tests/unit/test_api_explorer.py b/tests/unit/test_api_explorer.py
index 9b653fff..42908399 100644
--- a/tests/unit/test_api_explorer.py
+++ b/tests/unit/test_api_explorer.py
@@ -130,3 +130,15 @@ async def test_swagger_uses_proxy_mount_prefix_for_schema():
assert config["url"] == "/weave/openapi.json"
spec = (await client.get("/openapi.json")).json()
assert spec["servers"] == [{"url": "/weave"}]
+
+
+async def test_api_explorer_header_carries_no_mascot():
+ from firefly_weave.api.documentation import install_documentation
+
+ app = Starlette()
+ install_documentation(app)
+ async with AsyncClient(transport=ASGITransport(app), base_url="http://localhost") as client:
+ page = (await client.get("/docs")).text
+ assert "lumi" not in page.lower()
+ assert "
Firefly Weave · API explorer
" in page
diff --git a/tests/unit/test_brand_assets.py b/tests/unit/test_brand_assets.py
new file mode 100644
index 00000000..fdbbc5d4
--- /dev/null
+++ b/tests/unit/test_brand_assets.py
@@ -0,0 +1,116 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# Author: Firefly Software Foundation
+# SPDX-License-Identifier: Apache-2.0
+
+"""Brand foundation: no mascot remains, Studio's brand files match their masters, NOTICE separates the marks."""
+
+from __future__ import annotations
+
+import re
+import subprocess
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+# Diagram file names keep "lumi" (ai-and-lumi.svg); the mascot's files and markers do not survive.
+MASCOT = re.compile(r"(? list[tuple[str, str]]:
+ """Tracked and new, non-ignored text files; ignored private folders such as docs/superpowers never appear."""
+ listing = subprocess.run(
+ ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
+ cwd=ROOT,
+ check=True,
+ capture_output=True,
+ ).stdout.decode("utf-8")
+ found = []
+ for name in sorted(set(filter(None, listing.split("\0")))):
+ path = ROOT / name
+ if not path.is_file() or path == Path(__file__).resolve():
+ continue
+ data = path.read_bytes()
+ if b"\0" not in data[:8192]:
+ found.append((name, data.decode("utf-8", errors="replace")))
+ return found
+
+
+def test_no_mascot_file_drawing_or_label_remains():
+ hits = [f"{name}: {match.group(0)}" for name, text in repository_text_files() for match in MASCOT.finditer(text)]
+ assert hits == []
+ for name in ("assets/lumi.png", "assets/lumi.svg", "src/firefly_weave/branding.py"):
+ assert not (ROOT / name).exists(), name
+ assert not list((ROOT / "studio/public/assets").glob("lumi.*"))
+
+
+def test_no_brand_kit_source_is_committed():
+ assert [name for name, text in repository_text_files() if KIT_SOURCE.search(text)] == []
+
+
+@pytest.mark.parametrize(("copy", "master"), sorted(COPIES.items()))
+def test_studio_copies_are_byte_identical_to_their_masters(copy, master):
+ assert (ROOT / copy).read_bytes() == (ROOT / master).read_bytes()
+
+
+@pytest.mark.parametrize("name", SVGS)
+def test_brand_svgs_are_accessible_and_inert(name):
+ root = ET.parse(ROOT / name).getroot()
+ tags = {node.tag.rsplit("}", 1)[-1] for node in root.iter()}
+ assert root.attrib.get("role") == "img" and "viewBox" in root.attrib
+ assert {"title", "desc"} <= tags
+ assert not tags & {"script", "foreignObject", "image"}
+
+
+def test_expanded_sidebar_lockup_shows_the_firefly_logo_at_80px_or_more():
+ # The sidebar draws the lockup 200px wide; the nested is the Firefly logo.
+ root = ET.parse(ROOT / "studio/public/assets/weave-lockup-reversed.svg").getroot()
+ lockup_width = float(root.attrib["viewBox"].split()[2])
+ logo = root.find("{http://www.w3.org/2000/svg}svg")
+ assert logo is not None
+ assert float(logo.attrib["width"]) / lockup_width * 200 >= 80
+
+
+def test_notice_separates_the_marks_from_the_apache_license():
+ notice = (ROOT / "NOTICE").read_text(encoding="utf-8")
+ flat = " ".join(notice.split())
+ assert "trademarks and proprietary artwork of Firefly Software Solutions Inc." in flat
+ assert "uses them in Firefly Weave with the permission of Firefly Software Solutions Inc." in flat
+ assert "They are not licensed under the Apache License 2.0" in flat
+ assert "Manrope" in flat and "SIL Open Font License, Version 1.1" in flat
+ assert "Copyright 2019 The Manrope Project Authors" in flat
+ assert "Lucide" in flat and "lucide-static 1.52.0" in flat and "ISC License" in flat
+ assert "OWNER-CONFIRM" not in notice
+ assert not re.search(r"[\w.+-]+@[\w-]+\.[\w.]+", notice)
diff --git a/tests/unit/test_studio_host.py b/tests/unit/test_studio_host.py
index 0beafcc9..77b6e814 100644
--- a/tests/unit/test_studio_host.py
+++ b/tests/unit/test_studio_host.py
@@ -325,3 +325,21 @@ def test_browser_pairing_stays_single_use_after_unpairing(tmp_path):
with client(tmp_path / "late") as browser:
browser.app.state.studio.pairing_deadline = 0
assert browser.post("/studio/session", json={"code": CODE}, headers={"Origin": ORIGIN}).status_code == 403
+
+
+def test_brand_files_are_served_from_the_studio_origin_and_license_texts_are_not(tmp_path):
+ with client(tmp_path) as browser:
+ assets = tmp_path / "assets"
+ (assets / "fonts/manrope").mkdir(parents=True)
+ (assets / "fonts/manrope/manrope-latin-wght-normal.woff2").write_bytes(b"wOF2 font bytes")
+ (assets / "favicon.ico").write_bytes(b"\0\0\1\0 icon bytes")
+ (assets / "licenses").mkdir()
+ (assets / "licenses/NOTICE.txt").write_text("Firefly Weave\n")
+ (assets / "site.webmanifest").write_text("{}")
+ font = browser.get("/fonts/manrope/manrope-latin-wght-normal.woff2")
+ assert font.status_code == 200 and font.content == b"wOF2 font bytes"
+ icon = browser.get("/favicon.ico")
+ assert icon.status_code == 200 and icon.content == b"\0\0\1\0 icon bytes"
+ # Only the allowlisted suffixes are served: license texts and a web manifest in the bundle are not.
+ assert browser.get("/licenses/NOTICE.txt").status_code == 404
+ assert browser.get("/site.webmanifest").status_code == 404