Initial iris repository structure
Some checks failed
CI / build (push) Failing after 36s

WASM reactive UI framework for Go:
- reactive/ - Signal[T], Effect, Runtime
- ui/ - Button, Text, Input, View, Canvas, SVG components
- navigation/ - Router, guards, history management
- auth/ - OIDC client for WASM applications
- host/ - Static file server

Extracted from arcadia as open-source component.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-08 19:23:49 +01:00
commit 00d98879d3
36 changed files with 4181 additions and 0 deletions

21
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,21 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Build WASM
run: GOOS=js GOARCH=wasm go build ./...
- name: Build Host
run: go build ./host/...
- name: Test
run: go test ./...

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
# IDE
.idea/
.vscode/
*.swp
# OS
.DS_Store
Thumbs.db
# Build artifacts
*.wasm
/dist/
/build/
/bin/
# Go
/vendor/

101
CLAUDE.md Normal file
View File

@@ -0,0 +1,101 @@
# Iris
WASM reactive UI framework for Go.
## Organization Context
This repo is part of Flowmade. See:
- [Organization manifesto](../architecture/manifesto.md) - who we are, what we believe
- [Repository map](../architecture/repos.md) - how this fits in the bigger picture
- [Vision](./vision.md) - what this specific product does
## Setup
```bash
git clone git@git.flowmade.one:flowmade-one/iris.git
cd iris
```
## Project Structure
```
iris/
├── reactive/ # Signal[T], Effect, Runtime - core reactivity
├── ui/ # Components: Button, Text, Input, View, Canvas, SVG
├── navigation/ # Router, guards, history management
├── auth/ # OIDC client for WASM applications
├── host/ # Static file server for WASM apps
└── internal/ # Internal element abstraction
```
## Development
```bash
# Build (WASM target)
GOOS=js GOARCH=wasm go build ./...
# Build host server (native)
go build ./host/...
# Test
go test ./...
# Lint
golangci-lint run
```
## Architecture
### Reactivity Model
Iris uses a signals-based reactivity model:
```go
// Create a signal
count := reactive.NewSignal(0)
// Read value
fmt.Println(count.Get())
// Update value - triggers dependent effects
count.Set(count.Get() + 1)
// Create derived state
doubled := reactive.Computed(func() int {
return count.Get() * 2
})
```
### Components
All UI components implement a common interface and render to DOM elements:
```go
// Create a button
btn := ui.Button("Click me", func() {
count.Set(count.Get() + 1)
})
// Create reactive text
text := ui.Text(func() string {
return fmt.Sprintf("Count: %d", count.Get())
})
```
### Routing
Client-side routing with guards:
```go
router := navigation.NewRouter()
router.Route("/", homeView)
router.Route("/users/:id", userView)
router.Guard("/admin/*", authGuard)
```
## Key Patterns
- **No virtual DOM** - Direct DOM manipulation via syscall/js
- **Signals propagate** - Changes flow through the dependency graph
- **Components are functions** - Return DOM elements, not component instances
- **WASM-only UI code** - Use build tags for browser-specific code

190
LICENSE Normal file
View File

@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2024-2026 Flowmade
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.

16
Makefile Normal file
View File

@@ -0,0 +1,16 @@
.PHONY: build build-wasm test lint clean
build: build-wasm
go build ./host/...
build-wasm:
GOOS=js GOARCH=wasm go build ./...
test:
go test ./...
lint:
golangci-lint run
clean:
rm -f *.wasm

195
auth/http_wasm.go Normal file
View File

@@ -0,0 +1,195 @@
//go:build js && wasm
package auth
import (
"encoding/json"
"fmt"
"net/url"
"syscall/js"
)
// WASMHTTPClient handles HTTP requests in WASM environment
type WASMHTTPClient struct{}
// HTTPResult represents the result of an HTTP request
type HTTPResult struct {
Data []byte
Error error
}
// HTTPCallback is called when HTTP request completes
type HTTPCallback func(result HTTPResult)
// FetchJSON performs a GET request and unmarshals JSON response
// This method blocks and should only be called from the main thread
func (c *WASMHTTPClient) FetchJSON(url string, dest interface{}) error {
result := make(chan HTTPResult, 1)
c.FetchJSONAsync(url, func(r HTTPResult) {
result <- r
})
r := <-result
if r.Error != nil {
return r.Error
}
return json.Unmarshal(r.Data, dest)
}
// FetchJSONAsync performs a GET request asynchronously using callback
func (c *WASMHTTPClient) FetchJSONAsync(url string, callback HTTPCallback) {
// Create fetch promise
promise := js.Global().Call("fetch", url)
// Success handler
var successFunc js.Func
successFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
response := args[0]
if !response.Get("ok").Bool() {
callback(HTTPResult{
Error: fmt.Errorf("HTTP %d: %s", response.Get("status").Int(), response.Get("statusText").String()),
})
successFunc.Release()
return nil
}
// Get response text
textPromise := response.Call("text")
var textFunc js.Func
textFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
text := args[0].String()
callback(HTTPResult{
Data: []byte(text),
})
// Cleanup after callback completes
textFunc.Release()
successFunc.Release()
return nil
})
// Error handler for text promise
var textErrorFunc js.Func
textErrorFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
callback(HTTPResult{
Error: fmt.Errorf("failed to read response text: %v", args[0]),
})
// Cleanup
textFunc.Release()
textErrorFunc.Release()
successFunc.Release()
return nil
})
textPromise.Call("then", textFunc).Call("catch", textErrorFunc)
return nil
})
// Error handler
var errorFunc js.Func
errorFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
callback(HTTPResult{
Error: fmt.Errorf("fetch error: %s", args[0].String()),
})
// Cleanup after callback completes
errorFunc.Release()
successFunc.Release()
return nil
})
promise.Call("then", successFunc).Call("catch", errorFunc)
}
// PostForm performs a POST request with form data
func (c *WASMHTTPClient) PostForm(url string, data url.Values, dest interface{}) error {
result := make(chan HTTPResult, 1)
c.PostFormAsync(url, data, func(r HTTPResult) {
result <- r
})
r := <-result
if r.Error != nil {
return r.Error
}
return json.Unmarshal(r.Data, dest)
}
// PostFormAsync performs a POST request asynchronously using callback
func (c *WASMHTTPClient) PostFormAsync(url string, data url.Values, callback HTTPCallback) {
// Prepare fetch options
headers := map[string]interface{}{
"Content-Type": "application/x-www-form-urlencoded",
}
options := map[string]interface{}{
"method": "POST",
"headers": headers,
"body": data.Encode(),
}
// Convert options to JS object
jsOptions := js.ValueOf(options)
// Make the request
promise := js.Global().Call("fetch", url, jsOptions)
// Success handler
var successFunc js.Func
successFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
response := args[0]
if !response.Get("ok").Bool() {
callback(HTTPResult{
Error: fmt.Errorf("HTTP %d: %s", response.Get("status").Int(), response.Get("statusText").String()),
})
successFunc.Release()
return nil
}
// Get response text
textPromise := response.Call("text")
var textFunc js.Func
textFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
text := args[0].String()
callback(HTTPResult{
Data: []byte(text),
})
// Cleanup after callback completes
textFunc.Release()
successFunc.Release()
return nil
})
// Error handler for text promise
var textErrorFunc js.Func
textErrorFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
callback(HTTPResult{
Error: fmt.Errorf("failed to read response text: %v", args[0]),
})
// Cleanup
textFunc.Release()
textErrorFunc.Release()
successFunc.Release()
return nil
})
textPromise.Call("then", textFunc).Call("catch", textErrorFunc)
return nil
})
// Error handler
var errorFunc js.Func
errorFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
callback(HTTPResult{
Error: fmt.Errorf("fetch error: %s", args[0].String()),
})
// Cleanup after callback completes
errorFunc.Release()
successFunc.Release()
return nil
})
promise.Call("then", successFunc).Call("catch", errorFunc)
}

306
auth/oidc.go Normal file
View File

@@ -0,0 +1,306 @@
//go:build js && wasm
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"strings"
"syscall/js"
"time"
)
// OIDC types are defined in types.go
// OIDCClient handles OIDC authentication flows
type OIDCClient struct {
config *OIDCConfig
clientID string
redirectURI string
scopes []string
// Browser storage
localStorage js.Value
sessionStorage js.Value
// HTTP client for WASM
httpClient *WASMHTTPClient
}
// Token and user info types are defined in types.go
// NewOIDCClient creates a new OIDC client
func NewOIDCClient(issuer, clientID, redirectURI string) *OIDCClient {
client := &OIDCClient{
clientID: clientID,
redirectURI: redirectURI,
scopes: []string{"openid", "email", "profile"},
localStorage: js.Global().Get("localStorage"),
sessionStorage: js.Global().Get("sessionStorage"),
httpClient: &WASMHTTPClient{},
}
return client
}
// DiscoverConfig fetches the OIDC discovery configuration synchronously
func (c *OIDCClient) DiscoverConfig(issuer string) error {
discoveryURL := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration"
// Use fetch to get the configuration
return c.fetchJSON(discoveryURL, &c.config)
}
// DiscoverConfigAsync fetches the OIDC discovery configuration asynchronously
func (c *OIDCClient) DiscoverConfigAsync(issuer string, callback func(error)) {
discoveryURL := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration"
c.httpClient.FetchJSONAsync(discoveryURL, func(result HTTPResult) {
if result.Error != nil {
callback(result.Error)
return
}
// Parse the configuration
var config OIDCConfig
if err := json.Unmarshal(result.Data, &config); err != nil {
callback(fmt.Errorf("failed to parse OIDC config: %w", err))
return
}
c.config = &config
callback(nil)
})
}
// EnsureConfigLoaded ensures OIDC configuration is loaded before proceeding
func (c *OIDCClient) EnsureConfigLoaded(issuer string) error {
if c.config != nil {
return nil
}
return c.DiscoverConfig(issuer)
}
// StartAuthFlow initiates the OIDC authentication flow
func (c *OIDCClient) StartAuthFlow() error {
if c.config == nil {
return fmt.Errorf("OIDC configuration not loaded - call DiscoverConfig first")
}
// Generate PKCE parameters
verifier, err := c.generateCodeVerifier()
if err != nil {
return fmt.Errorf("failed to generate code verifier: %w", err)
}
challenge := c.generateCodeChallenge(verifier)
state := c.generateState()
// Store PKCE verifier and state in session storage
c.sessionStorage.Call("setItem", "pkce_verifier", verifier)
c.sessionStorage.Call("setItem", "auth_state", state)
// Build authorization URL
authURL, err := c.buildAuthURL(challenge, state)
if err != nil {
return fmt.Errorf("failed to build auth URL: %w", err)
}
// Redirect to Dex
js.Global().Get("window").Get("location").Set("href", authURL)
return nil
}
// HandleCallback processes the OAuth callback
func (c *OIDCClient) HandleCallback(issuer string) (*TokenResponse, error) {
// Ensure configuration is loaded before processing
if err := c.EnsureConfigLoaded(issuer); err != nil {
return nil, fmt.Errorf("failed to load OIDC configuration: %w", err)
}
// Get current URL parameters
urlParams := c.getURLParams()
code := urlParams.Get("code")
state := urlParams.Get("state")
errorParam := urlParams.Get("error")
if errorParam != "" {
return nil, fmt.Errorf("OAuth error: %s", errorParam)
}
if code == "" {
return nil, fmt.Errorf("authorization code not found in callback")
}
// Verify state parameter
storedState := c.sessionStorage.Call("getItem", "auth_state").String()
if state != storedState {
return nil, fmt.Errorf("invalid state parameter")
}
// Get PKCE verifier
verifier := c.sessionStorage.Call("getItem", "pkce_verifier").String()
if verifier == "" {
return nil, fmt.Errorf("PKCE verifier not found")
}
// Exchange code for tokens
return c.exchangeCodeForTokens(code, verifier)
}
// GetStoredTokens retrieves tokens from local storage
func (c *OIDCClient) GetStoredTokens() *TokenResponse {
accessToken := c.localStorage.Call("getItem", "access_token")
idToken := c.localStorage.Call("getItem", "id_token")
refreshToken := c.localStorage.Call("getItem", "refresh_token")
if accessToken.IsNull() || idToken.IsNull() {
return nil
}
return &TokenResponse{
AccessToken: accessToken.String(),
IDToken: idToken.String(),
RefreshToken: refreshToken.String(),
TokenType: "Bearer",
}
}
// StoreTokens saves tokens to local storage
func (c *OIDCClient) StoreTokens(tokens *TokenResponse) {
c.localStorage.Call("setItem", "access_token", tokens.AccessToken)
c.localStorage.Call("setItem", "id_token", tokens.IDToken)
if tokens.RefreshToken != "" {
c.localStorage.Call("setItem", "refresh_token", tokens.RefreshToken)
}
// Store expiration time
if tokens.ExpiresIn > 0 {
expiresAt := time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second)
c.localStorage.Call("setItem", "token_expires_at", expiresAt.Unix())
}
}
// ClearTokens removes all stored tokens
func (c *OIDCClient) ClearTokens() {
c.localStorage.Call("removeItem", "access_token")
c.localStorage.Call("removeItem", "id_token")
c.localStorage.Call("removeItem", "refresh_token")
c.localStorage.Call("removeItem", "token_expires_at")
}
// IsAuthenticated checks if the user has valid tokens
func (c *OIDCClient) IsAuthenticated() bool {
tokens := c.GetStoredTokens()
if tokens == nil {
return false
}
// Check if token is expired
expiresAtStr := c.localStorage.Call("getItem", "token_expires_at")
if !expiresAtStr.IsNull() {
// localStorage returns strings, need to convert to int
expiresAtString := expiresAtStr.String()
var expiresAt int64
if _, err := fmt.Sscanf(expiresAtString, "%d", &expiresAt); err == nil {
if time.Now().Unix() > expiresAt {
return false
}
}
}
return tokens.AccessToken != "" && tokens.IDToken != ""
}
// Logout clears all authentication data
func (c *OIDCClient) Logout() {
c.ClearTokens()
c.sessionStorage.Call("removeItem", "pkce_verifier")
c.sessionStorage.Call("removeItem", "auth_state")
}
// GetAuthHeader returns the Authorization header value
func (c *OIDCClient) GetAuthHeader() string {
tokens := c.GetStoredTokens()
if tokens == nil {
return ""
}
return "Bearer " + tokens.IDToken
}
// Helper methods
func (c *OIDCClient) generateCodeVerifier() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(bytes), nil
}
func (c *OIDCClient) generateCodeChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(hash[:])
}
func (c *OIDCClient) generateState() string {
bytes := make([]byte, 16)
rand.Read(bytes)
return base64.RawURLEncoding.EncodeToString(bytes)
}
func (c *OIDCClient) buildAuthURL(challenge, state string) (string, error) {
params := url.Values{
"client_id": {c.clientID},
"response_type": {"code"},
"scope": {strings.Join(c.scopes, " ")},
"redirect_uri": {c.redirectURI},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
"state": {state},
}
authURL := c.config.AuthURL + "?" + params.Encode()
return authURL, nil
}
func (c *OIDCClient) getURLParams() url.Values {
location := js.Global().Get("window").Get("location")
search := location.Get("search").String()
params, _ := url.ParseQuery(strings.TrimPrefix(search, "?"))
return params
}
func (c *OIDCClient) exchangeCodeForTokens(code, verifier string) (*TokenResponse, error) {
// Prepare form data
data := url.Values{
"grant_type": {"authorization_code"},
"client_id": {c.clientID},
"code": {code},
"redirect_uri": {c.redirectURI},
"code_verifier": {verifier},
}
// Make POST request to token endpoint
var tokens TokenResponse
if err := c.postForm(c.config.TokenURL, data, &tokens); err != nil {
return nil, fmt.Errorf("token exchange failed: %w", err)
}
return &tokens, nil
}
// fetchJSON performs a GET request and unmarshals JSON response
func (c *OIDCClient) fetchJSON(url string, dest interface{}) error {
return c.httpClient.FetchJSON(url, dest)
}
// postForm performs a POST request with form data
func (c *OIDCClient) postForm(url string, data url.Values, dest interface{}) error {
return c.httpClient.PostForm(url, data, dest)
}

30
auth/types.go Normal file
View File

@@ -0,0 +1,30 @@
package auth
// OIDCConfig holds the OIDC provider configuration
type OIDCConfig struct {
Issuer string `json:"issuer"`
AuthURL string `json:"authorization_endpoint"`
TokenURL string `json:"token_endpoint"`
UserInfoURL string `json:"userinfo_endpoint"`
JWKSURL string `json:"jwks_uri"`
ScopesSupported []string `json:"scopes_supported"`
}
// TokenResponse represents the response from token exchange
type TokenResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
// UserInfo represents user information from the OIDC provider
type UserInfo struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Groups []string `json:"groups,omitempty"`
}

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module git.flowmade.one/flowmade-one/iris
go 1.23

150
host/server.go Normal file
View File

@@ -0,0 +1,150 @@
package host
import (
"compress/gzip"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
// Server provides a high-performance static file server with gzip compression
// optimized for serving WASM applications built with the Iris framework.
type Server struct {
publicDir string
indexFile string
}
// New creates a new Server instance
func New(publicDir, indexFile string) *Server {
return &Server{
publicDir: publicDir,
indexFile: indexFile,
}
}
// ServeHTTP implements http.Handler interface
// Falls back to index file when:
// (1) Request path is not found
// (2) Request path is a directory
// Otherwise serves the requested file with gzip compression if supported.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p := filepath.Join(s.publicDir, filepath.Clean(r.URL.Path))
if info, err := os.Stat(p); err != nil {
s.serveFileWithCompression(w, r, filepath.Join(s.publicDir, s.indexFile))
return
} else if info.IsDir() {
s.serveFileWithCompression(w, r, filepath.Join(s.publicDir, s.indexFile))
return
}
s.serveFileWithCompression(w, r, p)
}
// serveFileWithCompression serves a file with gzip compression if supported by client
func (s *Server) serveFileWithCompression(w http.ResponseWriter, r *http.Request, filePath string) {
// Check if client accepts gzip
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
http.ServeFile(w, r, filePath)
return
}
// Open and read file
file, err := os.Open(filePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
defer file.Close()
// Get file info for headers
fileInfo, err := file.Stat()
if err != nil {
http.Error(w, "Error reading file", http.StatusInternalServerError)
return
}
// Set content type based on file extension
contentType := getContentType(filepath.Ext(filePath))
w.Header().Set("Content-Type", contentType)
// Check if we should compress this file type
if shouldCompress(contentType) {
// Set gzip headers
w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Vary", "Accept-Encoding")
// Create gzip writer
gz := gzip.NewWriter(w)
defer gz.Close()
// Copy file content through gzip
_, err = io.Copy(gz, file)
if err != nil {
http.Error(w, "Error compressing file", http.StatusInternalServerError)
return
}
} else {
// Serve without compression
http.ServeContent(w, r, fileInfo.Name(), fileInfo.ModTime(), file)
}
}
// shouldCompress determines if the content should be compressed based on Content-Type
func shouldCompress(contentType string) bool {
compressibleTypes := []string{
"text/html",
"text/css",
"text/javascript",
"application/javascript",
"application/json",
"application/wasm",
"text/plain",
"image/svg+xml",
}
for _, t := range compressibleTypes {
if strings.Contains(contentType, t) {
return true
}
}
return false
}
// getContentType returns the appropriate content type for a file extension
func getContentType(ext string) string {
switch ext {
case ".html":
return "text/html; charset=utf-8"
case ".css":
return "text/css"
case ".js":
return "application/javascript"
case ".wasm":
return "application/wasm"
case ".json":
return "application/json"
case ".ico":
return "image/x-icon"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".svg":
return "image/svg+xml"
case ".woff":
return "font/woff"
case ".woff2":
return "font/woff2"
case ".ttf":
return "font/ttf"
case ".eot":
return "application/vnd.ms-fontobject"
default:
return "application/octet-stream"
}
}

283
internal/element/element.go Normal file
View File

@@ -0,0 +1,283 @@
//build +js,wasm
package element
import (
"fmt"
"syscall/js"
)
type Element struct {
js.Value
}
var doc = js.Global().Get("document")
func Document() Element {
return Element{doc}
}
func NewElement(tag string) Element {
doc := Document()
e := doc.Call("createElement", tag)
return Element{e}
}
func NewTextNode(text string) Element {
doc := Document()
textNode := doc.Call("createTextNode", text)
return Element{textNode}
}
func (e Element) On(event string, fn func()) Element {
e.Call("addEventListener", event, js.FuncOf(func(this js.Value, args []js.Value) interface{} {
fn()
return nil
}))
return e
}
func (e Element) Margin(margin string) Element {
e.Get("style").Call("setProperty", "margin", margin)
return e
}
func (e Element) Padding(padding string) Element {
e.Get("style").Call("setProperty", "padding", padding)
return e
}
func (e Element) Height(height string) Element {
e.Get("style").Call("setProperty", "height", height)
return e
}
func (e Element) MinHeight(height string) Element {
e.Get("style").Call("setProperty", "min-height", height)
return e
}
func (e Element) Color(color string) Element {
e.Get("style").Call("setProperty", "color", color)
return e
}
func (e Element) Width(width string) Element {
e.Get("style").Call("setProperty", "width", width)
return e
}
func (e Element) BackgroundColor(color string) Element {
e.Get("style").Call("setProperty", "background-color", color)
return e
}
func (e Element) Attr(name, value string) Element {
e.Call("setAttribute", name, value)
return e
}
func (e Element) Text(text string) Element {
doc := Document()
textNode := doc.Call("createTextNode", text)
e.Call("appendChild", textNode)
return e
}
func (e Element) Child(child Element) Element {
e.Call("appendChild", child.Value)
return e
}
func Mount(root Element) {
doc := Document()
body := doc.Get("body")
body.Call("appendChild", root.Value)
}
func (e Element) Flex() Element {
e.Get("style").Call("setProperty", "display", "flex")
return e
}
func (e Element) FlexDirection(direction string) Element {
e.Get("style").Call("setProperty", "flex-direction", direction)
return e
}
func (e Element) JustifyContent(justify string) Element {
e.Get("style").Call("setProperty", "justify-content", justify)
return e
}
func (e Element) JustifyItems(justify string) Element {
e.Get("style").Call("setProperty", "justify-items", justify)
return e
}
func (e Element) AlignItems(align string) Element {
e.Get("style").Call("setProperty", "align-items", align)
return e
}
func (e Element) FlexGrow(grow int) Element {
e.Get("style").Call("setProperty", "flex-grow", grow)
return e
}
func (e Element) FlexWrap(wrap string) Element {
e.Get("style").Call("setProperty", "flex-wrap", wrap)
return e
}
func (e Element) Grid() Element {
e.Get("style").Call("setProperty", "display", "grid")
return e
}
func (e Element) GridAutoFlow(flow string) Element {
e.Get("style").Call("setProperty", "grid-auto-flow", flow)
return e
}
func (e Element) GridTemplateColumns(columns string) Element {
e.Get("style").Call("setProperty", "grid-template-columns", columns)
return e
}
func (e Element) GridTemplateRows(rows string) Element {
e.Get("style").Call("setProperty", "grid-template-rows", rows)
return e
}
func (e Element) MaxWidth(width string) Element {
e.Get("style").Call("setProperty", "max-width", width)
return e
}
// OnWithEvent adds an event listener that provides access to the event object
func (e Element) OnWithEvent(event string, fn func(js.Value)) Element {
e.Call("addEventListener", event, js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) > 0 {
fn(args[0])
}
return nil
}))
return e
}
// Positioning methods
func (e Element) Position(pos string) Element {
e.Get("style").Call("setProperty", "position", pos)
return e
}
func (e Element) Left(value string) Element {
e.Get("style").Call("setProperty", "left", value)
return e
}
func (e Element) Top(value string) Element {
e.Get("style").Call("setProperty", "top", value)
return e
}
func (e Element) Right(value string) Element {
e.Get("style").Call("setProperty", "right", value)
return e
}
func (e Element) Bottom(value string) Element {
e.Get("style").Call("setProperty", "bottom", value)
return e
}
func (e Element) ZIndex(value int) Element {
e.Get("style").Call("setProperty", "z-index", fmt.Sprintf("%d", value))
return e
}
// Transform methods
func (e Element) Transform(value string) Element {
e.Get("style").Call("setProperty", "transform", value)
return e
}
func (e Element) TransformOrigin(value string) Element {
e.Get("style").Call("setProperty", "transform-origin", value)
return e
}
// Cursor and interaction
func (e Element) Cursor(value string) Element {
e.Get("style").Call("setProperty", "cursor", value)
return e
}
func (e Element) UserSelect(value string) Element {
e.Get("style").Call("setProperty", "user-select", value)
return e
}
func (e Element) PointerEvents(value string) Element {
e.Get("style").Call("setProperty", "pointer-events", value)
return e
}
// Overflow
func (e Element) Overflow(value string) Element {
e.Get("style").Call("setProperty", "overflow", value)
return e
}
// Box styling
func (e Element) BoxShadow(value string) Element {
e.Get("style").Call("setProperty", "box-shadow", value)
return e
}
func (e Element) BorderRadius(value string) Element {
e.Get("style").Call("setProperty", "border-radius", value)
return e
}
func (e Element) Border(value string) Element {
e.Get("style").Call("setProperty", "border", value)
return e
}
// DOM manipulation
func (e Element) RemoveChild(child Element) Element {
e.Call("removeChild", child.Value)
return e
}
func (e Element) ClearChildren() Element {
e.Set("innerHTML", "")
return e
}
// SVG support
func NewElementNS(namespace, tag string) Element {
doc := Document()
e := doc.Call("createElementNS", namespace, tag)
return Element{e}
}
// AttrNS sets an attribute with namespace (for SVG)
func (e Element) AttrNS(namespace, name, value string) Element {
if namespace == "" {
e.Call("setAttribute", name, value)
} else {
e.Call("setAttributeNS", namespace, name, value)
}
return e
}

90
navigation/components.go Normal file
View File

@@ -0,0 +1,90 @@
package navigation
import (
"git.flowmade.one/flowmade-one/iris/reactive"
"git.flowmade.one/flowmade-one/iris/ui"
)
var globalRouter *Router
var globalHistory *HistoryManager
func SetGlobalRouter(router *Router) {
globalRouter = router
globalHistory = NewHistoryManager(router)
globalHistory.Start()
}
func GetGlobalRouter() *Router {
return globalRouter
}
// RouterView renders the current route's view
func RouterView() ui.View {
if globalRouter == nil {
return ui.TextFromString("Router not initialized").Color("#ff4444")
}
// Create a view that updates when the route changes
view := ui.NewView()
reactive.NewEffect(func() {
currentView := globalRouter.GetCurrentView()
// Clear previous content and add new view
// Note: This is a simplified approach - in a real implementation
// we'd need better DOM diffing
view = currentView
})
return view
}
// Link creates a navigational link that updates the route
func Link(path string, content ui.View) ui.View {
if globalHistory == nil {
return content
}
// Create a simple clickable element that navigates with proper styling for horizontal layout
button := ui.Button(func() {
globalHistory.PushState(path)
}, content)
// Style the button to look like a link and work in horizontal layouts
return button.Padding("8px 12px").Background("transparent").Border("none")
}
// Navigate programmatically navigates to a path
func Navigate(path string) {
if globalHistory != nil {
globalHistory.PushState(path)
}
}
// Replace programmatically replaces current path
func Replace(path string) {
if globalHistory != nil {
globalHistory.ReplaceState(path)
}
}
// Back navigates back in history
func Back() {
if globalHistory != nil {
globalHistory.Back()
}
}
// Forward navigates forward in history
func Forward() {
if globalHistory != nil {
globalHistory.Forward()
}
}
// GetCurrentPath returns the current route path
func GetCurrentPath() string {
if globalRouter != nil {
return globalRouter.GetCurrentPath()
}
return ""
}

69
navigation/core.go Normal file
View File

@@ -0,0 +1,69 @@
package navigation
import (
"strings"
)
// Core routing logic separated from UI dependencies
type RoutePattern struct {
parts []string
params []string
}
func CompileRoutePattern(path string) RoutePattern {
var params []string
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) == 1 && parts[0] == "" {
parts = []string{} // Handle root path
}
// Find parameters like :id, :name, etc.
for _, part := range parts {
if strings.HasPrefix(part, ":") {
paramName := part[1:] // Remove the ':'
// Simple validation: param name should start with letter
if len(paramName) > 0 && isLetterCore(paramName[0]) {
params = append(params, paramName)
}
}
}
return RoutePattern{parts, params}
}
func MatchRoute(routePattern RoutePattern, path string) map[string]string {
pathParts := strings.Split(strings.Trim(path, "/"), "/")
if len(pathParts) == 1 && pathParts[0] == "" {
pathParts = []string{} // Handle root path
}
if len(pathParts) != len(routePattern.parts) {
return nil
}
params := make(map[string]string)
paramIndex := 0
for i, routePart := range routePattern.parts {
if strings.HasPrefix(routePart, ":") {
// This is a parameter
if paramIndex < len(routePattern.params) {
params[routePattern.params[paramIndex]] = pathParts[i]
paramIndex++
}
} else {
// Static part must match exactly
if routePart != pathParts[i] {
return nil
}
}
}
return params
}
// isLetterCore checks if a byte is a letter
func isLetterCore(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
}

91
navigation/guards.go Normal file
View File

@@ -0,0 +1,91 @@
package navigation
import "strings"
// Common route guards
// AuthGuard checks if user is authenticated
func AuthGuard(isAuthenticated func() bool) RouteGuard {
return func(route *Route, params map[string]string) bool {
return isAuthenticated()
}
}
// RoleGuard checks if user has required role
func RoleGuard(userRole func() string, requiredRole string) RouteGuard {
return func(route *Route, params map[string]string) bool {
return userRole() == requiredRole
}
}
// PermissionGuard checks if user has specific permission
func PermissionGuard(hasPermission func(permission string) bool, permission string) RouteGuard {
return func(route *Route, params map[string]string) bool {
return hasPermission(permission)
}
}
// ParamValidationGuard validates route parameters
func ParamValidationGuard(validator func(params map[string]string) bool) RouteGuard {
return func(route *Route, params map[string]string) bool {
return validator(params)
}
}
// PathPrefixGuard checks if the current path starts with a specific prefix
func PathPrefixGuard(prefix string) RouteGuard {
return func(route *Route, params map[string]string) bool {
currentPath := GetCurrentPath()
return strings.HasPrefix(currentPath, prefix)
}
}
// CombineGuards combines multiple guards with AND logic
func CombineGuards(guards ...RouteGuard) RouteGuard {
return func(route *Route, params map[string]string) bool {
for _, guard := range guards {
if !guard(route, params) {
return false
}
}
return true
}
}
// AnyGuard combines multiple guards with OR logic
func AnyGuard(guards ...RouteGuard) RouteGuard {
return func(route *Route, params map[string]string) bool {
for _, guard := range guards {
if guard(route, params) {
return true
}
}
return false
}
}
// Example usage patterns:
// AdminOnlyGuard - combines authentication and role checks
func AdminOnlyGuard(isAuth func() bool, userRole func() string) RouteGuard {
return CombineGuards(
AuthGuard(isAuth),
RoleGuard(userRole, "admin"),
)
}
// NumericIdGuard - validates that :id parameter is numeric
func NumericIdGuard() RouteGuard {
return ParamValidationGuard(func(params map[string]string) bool {
if id, exists := params["id"]; exists {
// Simple numeric check
for _, char := range id {
if char < '0' || char > '9' {
return false
}
}
return len(id) > 0
}
return true // No id param, so validation passes
})
}

63
navigation/history.go Normal file
View File

@@ -0,0 +1,63 @@
//build +js,wasm
package navigation
import (
"syscall/js"
)
type HistoryManager struct {
router *Router
window js.Value
}
func NewHistoryManager(router *Router) *HistoryManager {
return &HistoryManager{
router: router,
window: js.Global().Get("window"),
}
}
func (h *HistoryManager) Start() {
// Handle initial navigation
currentPath := h.getCurrentPath()
h.router.Navigate(currentPath)
// Listen for browser back/forward events
h.window.Call("addEventListener", "popstate", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
path := h.getCurrentPath()
h.router.Navigate(path)
return nil
}))
}
func (h *HistoryManager) PushState(path string) {
h.window.Get("history").Call("pushState", nil, "", path)
h.router.Navigate(path)
}
func (h *HistoryManager) ReplaceState(path string) {
h.window.Get("history").Call("replaceState", nil, "", path)
h.router.Navigate(path)
}
func (h *HistoryManager) Back() {
h.window.Get("history").Call("back")
}
func (h *HistoryManager) Forward() {
h.window.Get("history").Call("forward")
}
func (h *HistoryManager) getCurrentPath() string {
location := h.window.Get("location")
pathname := location.Get("pathname").String()
search := location.Get("search").String()
path := pathname
if search != "" {
path += search
}
return path
}

157
navigation/router.go Normal file
View File

@@ -0,0 +1,157 @@
package navigation
import (
"strings"
"git.flowmade.one/flowmade-one/iris/reactive"
"git.flowmade.one/flowmade-one/iris/ui"
)
type RouteGuard func(route *Route, params map[string]string) bool
type Route struct {
Path string
Handler func(params map[string]string) ui.View
Guards []RouteGuard
parts []string
params []string
}
type Router struct {
routes []Route
currentPath *reactive.Signal[string]
currentView *reactive.Signal[ui.View]
notFound func() ui.View
}
func NewRouter(routes []Route) *Router {
currentPath := reactive.NewSignal("")
currentView := reactive.NewSignal(ui.NewView())
router := &Router{
routes: make([]Route, len(routes)),
currentPath: &currentPath,
currentView: &currentView,
notFound: defaultNotFound,
}
// Parse route patterns
for i, route := range routes {
parsedRoute := route
parsedRoute.parts, parsedRoute.params = parseRoute(route.Path)
router.routes[i] = parsedRoute
}
return router
}
func (r *Router) SetNotFoundHandler(handler func() ui.View) {
r.notFound = handler
}
func (r *Router) Navigate(path string) {
r.currentPath.Set(path)
r.updateView(path)
}
func (r *Router) GetCurrentPath() string {
return r.currentPath.Get()
}
func (r *Router) GetCurrentView() ui.View {
return r.currentView.Get()
}
func (r *Router) updateView(path string) {
for _, route := range r.routes {
if params := r.matchRoute(&route, path); params != nil {
// Check guards
if r.checkGuards(&route, params) {
view := route.Handler(params)
r.currentView.Set(view)
return
}
}
}
// No route matched or guards failed
r.currentView.Set(r.notFound())
}
func (r *Router) matchRoute(route *Route, path string) map[string]string {
// Strip query parameters for route matching
if queryIndex := strings.Index(path, "?"); queryIndex != -1 {
path = path[:queryIndex]
}
pathParts := strings.Split(strings.Trim(path, "/"), "/")
if len(pathParts) == 1 && pathParts[0] == "" {
pathParts = []string{} // Handle root path
}
if len(pathParts) != len(route.parts) {
return nil
}
params := make(map[string]string)
paramIndex := 0
for i, routePart := range route.parts {
if strings.HasPrefix(routePart, ":") {
// This is a parameter
if paramIndex < len(route.params) {
params[route.params[paramIndex]] = pathParts[i]
paramIndex++
}
} else {
// Static part must match exactly
if routePart != pathParts[i] {
return nil
}
}
}
return params
}
func (r *Router) checkGuards(route *Route, params map[string]string) bool {
for _, guard := range route.Guards {
if !guard(route, params) {
return false
}
}
return true
}
func parseRoute(path string) ([]string, []string) {
var params []string
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) == 1 && parts[0] == "" {
parts = []string{} // Handle root path
}
// Find parameters like :id, :name, etc.
for _, part := range parts {
if strings.HasPrefix(part, ":") {
paramName := part[1:] // Remove the ':'
// Simple validation: param name should start with letter
if len(paramName) > 0 && isLetter(paramName[0]) {
params = append(params, paramName)
}
}
}
return parts, params
}
// isLetter checks if a byte is a letter
func isLetter(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
}
func defaultNotFound() ui.View {
return ui.VerticalGroup(
ui.TextFromString("404 - Page Not Found").Color("#ff4444"),
ui.TextFromString("The requested page could not be found.").Color("#666"),
)
}

18
reactive/effect.go Normal file
View File

@@ -0,0 +1,18 @@
package reactive
type EffectId int
func NewEffect(f func()) {
rt := GetRuntime()
effectId := EffectId(len(rt.effects))
rt.effects = append(rt.effects, f)
runEffect(rt, effectId)
}
func NewEffectWithRuntime(rt *Runtime, f func()) {
effectId := EffectId(len(rt.effects))
rt.effects = append(rt.effects, f)
runEffect(rt, effectId)
}

39
reactive/runtime.go Normal file
View File

@@ -0,0 +1,39 @@
package reactive
import "sync"
type Runtime struct {
signalValues []any
runningEffect *EffectId
signalSubscribers map[SignalId][]EffectId
effects []func()
}
var lock = &sync.Mutex{}
var runtimeInstance *Runtime
func GetRuntime() *Runtime {
if runtimeInstance == nil {
lock.Lock()
defer lock.Unlock()
if runtimeInstance == nil {
runtimeInstance = newRuntime()
}
}
return runtimeInstance
}
func newRuntime() *Runtime {
return &Runtime{
signalValues: []any{},
signalSubscribers: map[SignalId][]EffectId{},
effects: []func(){},
}
}
func runEffect(rt *Runtime, id EffectId) {
previous := rt.runningEffect
rt.runningEffect = &id
rt.effects[id]()
rt.runningEffect = previous
}

52
reactive/signal.go Normal file
View File

@@ -0,0 +1,52 @@
package reactive
type SignalId int
type Signal[T any] struct {
*Runtime
SignalId
}
func NewSignal[T any](initial T) Signal[T] {
rt := GetRuntime()
id := SignalId(len(rt.signalValues))
rt.signalValues = append(rt.signalValues, initial)
return Signal[T]{rt, id}
}
func NewSignalWithRuntime[T any](rt *Runtime, initial T) Signal[T] {
id := SignalId(len(rt.signalValues))
rt.signalValues = append(rt.signalValues, initial)
return Signal[T]{rt, id}
}
func (s *Signal[T]) Get() T {
val := s.Runtime.signalValues[s.SignalId]
if rt := s.Runtime; rt.runningEffect != nil {
// Check if this effect is already subscribed to this signal
subscribers := rt.signalSubscribers[s.SignalId]
alreadySubscribed := false
for _, effectId := range subscribers {
if effectId == *rt.runningEffect {
alreadySubscribed = true
break
}
}
if !alreadySubscribed {
rt.signalSubscribers[s.SignalId] = append(rt.signalSubscribers[s.SignalId], *rt.runningEffect)
}
}
return val.(T)
}
func (s *Signal[T]) Set(v T) {
s.Runtime.signalValues[s.SignalId] = v
if subscribers, ok := s.Runtime.signalSubscribers[s.SignalId]; ok {
for _, effectId := range subscribers {
runEffect(s.Runtime, effectId)
}
}
}

15
ui/app.go Normal file
View File

@@ -0,0 +1,15 @@
package ui
import (
_ "syscall/js"
"git.flowmade.one/flowmade-one/iris/internal/element"
)
func NewApp(view View) {
app := NewView()
app.e.Grid()
app.MinHeight("100vh")
app.Child(view)
element.Mount(app.e)
}

29
ui/app_router.go Normal file
View File

@@ -0,0 +1,29 @@
package ui
import (
"git.flowmade.one/flowmade-one/iris/internal/element"
"git.flowmade.one/flowmade-one/iris/reactive"
)
// Router interface to avoid import cycle
type Router interface {
GetCurrentView() View
}
// NewAppWithRouter creates a new app with navigation support
func NewAppWithRouter(router Router) {
app := NewView()
app.e.Grid()
app.MinHeight("100vh")
// Create a reactive view that updates when routes change
reactive.NewEffect(func() {
currentView := router.GetCurrentView()
// Clear the app and add the current view
// Note: In a production implementation, we'd want better DOM diffing
app.e.Set("innerHTML", "")
app.Child(currentView)
})
element.Mount(app.e)
}

9
ui/button.go Normal file
View File

@@ -0,0 +1,9 @@
package ui
import "git.flowmade.one/flowmade-one/iris/internal/element"
func Button(action func(), label View) View {
btn := View{element.NewElement("button").On("click", action)}
btn.Child(label)
return btn
}

1124
ui/canvas.go Normal file

File diff suppressed because it is too large Load Diff

51
ui/canvas_config.go Normal file
View File

@@ -0,0 +1,51 @@
//go:build js && wasm
package ui
// CanvasDefaults contains default values for canvas rendering and interaction
type CanvasDefaults struct {
// Interaction thresholds
EdgeThreshold float64 // Distance from edge to trigger connection drawing (default: 15)
DragThreshold float64 // Minimum movement to count as drag vs click (default: 2)
// Resize constraints
MinResizeWidth float64 // Minimum width when resizing (default: 100)
MinResizeHeight float64 // Minimum height when resizing (default: 100)
// Resize handle sizes
CornerHandleSize float64 // Size of corner resize handles (default: 12)
EdgeHandleWidth float64 // Width of edge resize handles (default: 8)
EdgeHandleLength float64 // Length of edge resize handles (default: 24)
// Connection rendering
ConnectionHitWidth float64 // Width of invisible hit area for connections (default: 12)
LabelBackgroundWidth float64 // Width of label background rect (default: 60)
LabelBackgroundHeight float64 // Height of label background rect (default: 20)
// Container layout
ContainerHeaderHeight float64 // Height of container header bar for click detection (default: 32)
}
// DefaultCanvasDefaults returns the default configuration values
var DefaultCanvasDefaults = CanvasDefaults{
// Interaction thresholds
EdgeThreshold: 15.0,
DragThreshold: 2.0,
// Resize constraints
MinResizeWidth: 100.0,
MinResizeHeight: 100.0,
// Resize handle sizes
CornerHandleSize: 12.0,
EdgeHandleWidth: 8.0,
EdgeHandleLength: 24.0,
// Connection rendering
ConnectionHitWidth: 12.0,
LabelBackgroundWidth: 60.0,
LabelBackgroundHeight: 20.0,
// Container layout
ContainerHeaderHeight: 32.0,
}

36
ui/canvas_errors.go Normal file
View File

@@ -0,0 +1,36 @@
//go:build js && wasm
package ui
import "log"
// CanvasWarning represents a type of canvas warning
type CanvasWarning int
const (
WarnItemNotFound CanvasWarning = iota
WarnConnectionNotFound
WarnInvalidOperation
WarnSelfConnection
WarnMissingCallback
)
// warningNames maps warning types to human-readable names
var warningNames = map[CanvasWarning]string{
WarnItemNotFound: "ItemNotFound",
WarnConnectionNotFound: "ConnectionNotFound",
WarnInvalidOperation: "InvalidOperation",
WarnSelfConnection: "SelfConnection",
WarnMissingCallback: "MissingCallback",
}
// LogCanvasWarning logs a warning for recoverable canvas issues.
// These are situations where the operation cannot complete but the
// application can continue safely.
func LogCanvasWarning(warn CanvasWarning, format string, args ...any) {
name := warningNames[warn]
if name == "" {
name = "Unknown"
}
log.Printf("[Canvas %s] "+format, append([]any{name}, args...)...)
}

144
ui/canvas_geometry.go Normal file
View File

@@ -0,0 +1,144 @@
//go:build js && wasm
package ui
import (
"sort"
"git.flowmade.one/flowmade-one/iris/internal/element"
)
// snapToGrid snaps a position to the grid if enabled
func snapToGrid(pos Point, gridSize int) Point {
if gridSize <= 0 {
return pos
}
return Point{
X: float64(int(pos.X/float64(gridSize)+0.5) * gridSize),
Y: float64(int(pos.Y/float64(gridSize)+0.5) * gridSize),
}
}
// findItemUnderPoint finds which canvas item is under the given screen coordinates
// Returns the item with highest ZIndex that contains the point
func findItemUnderPoint(state *CanvasState, viewport element.Element, clientX, clientY float64) string {
// Get viewport bounds
rect := viewport.Call("getBoundingClientRect")
viewportLeft := rect.Get("left").Float()
viewportTop := rect.Get("top").Float()
// Convert to canvas coordinates
x := clientX - viewportLeft
y := clientY - viewportTop
canvasPos := state.ScreenToCanvas(Point{X: x, Y: y})
// Sort items by ZIndex descending (higher values first = top items first)
items := state.Items.Get()
sortedItems := make([]CanvasItem, len(items))
copy(sortedItems, items)
sort.Slice(sortedItems, func(i, j int) bool {
return sortedItems[i].ZIndex > sortedItems[j].ZIndex
})
// Check items in z-order (top to bottom)
for _, item := range sortedItems {
if canvasPos.X >= item.Position.X && canvasPos.X <= item.Position.X+item.Size.X &&
canvasPos.Y >= item.Position.Y && canvasPos.Y <= item.Position.Y+item.Size.Y {
return item.ID
}
}
return ""
}
// isNearEdge checks if a click position is near the edge of an element
func isNearEdge(clickX, clickY, elemWidth, elemHeight float64) bool {
edgeThreshold := DefaultCanvasDefaults.EdgeThreshold
return clickX < edgeThreshold ||
clickX > elemWidth-edgeThreshold ||
clickY < edgeThreshold ||
clickY > elemHeight-edgeThreshold
}
// calculateEdgePoint calculates the edge point for a connection based on click position
func calculateEdgePoint(item CanvasItem, clickX, clickY, elemWidth, elemHeight float64) Point {
// Calculate center of element
centerX := item.Position.X + item.Size.X/2
centerY := item.Position.Y + item.Size.Y/2
// Determine which edge is closest
distLeft := clickX
distRight := elemWidth - clickX
distTop := clickY
distBottom := elemHeight - clickY
minDist := distLeft
edgeX := item.Position.X
edgeY := centerY
if distRight < minDist {
minDist = distRight
edgeX = item.Position.X + item.Size.X
edgeY = centerY
}
if distTop < minDist {
minDist = distTop
edgeX = centerX
edgeY = item.Position.Y
}
if distBottom < minDist {
edgeX = centerX
edgeY = item.Position.Y + item.Size.Y
}
return Point{X: edgeX, Y: edgeY}
}
// calculateConnectionEdgePoint calculates where a line from center to target
// should intersect with the element's bounding box
func calculateConnectionEdgePoint(center, target, size Point) Point {
dx := target.X - center.X
dy := target.Y - center.Y
// Handle edge case of overlapping centers
if dx == 0 && dy == 0 {
return center
}
// Calculate intersection with bounding box
halfW := size.X / 2
halfH := size.Y / 2
// Check which edge we intersect
if dx == 0 {
// Vertical line
if dy > 0 {
return Point{X: center.X, Y: center.Y + halfH}
}
return Point{X: center.X, Y: center.Y - halfH}
}
slope := dy / dx
// Check horizontal edges
if abs(slope) <= halfH/halfW {
// Intersects left or right edge
if dx > 0 {
return Point{X: center.X + halfW, Y: center.Y + slope*halfW}
}
return Point{X: center.X - halfW, Y: center.Y - slope*halfW}
}
// Intersects top or bottom edge
if dy > 0 {
return Point{X: center.X + halfH/slope, Y: center.Y + halfH}
}
return Point{X: center.X - halfH/slope, Y: center.Y - halfH}
}
// abs returns the absolute value of x
func abs(x float64) float64 {
if x < 0 {
return -x
}
return x
}

42
ui/font.go Normal file
View File

@@ -0,0 +1,42 @@
package ui
type Font struct {
family string
size string
weight string
lineheight string
}
func NewFont() Font {
return Font{
family: "sans-serif",
size: "16px",
weight: "400",
lineheight: "20px",
}
}
func (f Font) Family(family string) Font {
f.family = family
return f
}
func (f Font) Size(size string) Font {
f.size = size
return f
}
func (f Font) Weight(weight string) Font {
f.weight = weight
return f
}
func (f Font) String() string {
return f.weight + " " + f.size + " " + f.family + ""
}
// todo we can't apply this straight via the css, we need to apply it to the lineheight of the parent
func (f Font) LineHeight(lineheight string) Font {
f.lineheight = lineheight
return f
}

7
ui/image.go Normal file
View File

@@ -0,0 +1,7 @@
package ui
import "git.flowmade.one/flowmade-one/iris/internal/element"
func Image(src string) View {
return View{element.NewElement("img").Attr("src", src)}
}

208
ui/input.go Normal file
View File

@@ -0,0 +1,208 @@
package ui
import (
"strconv"
"git.flowmade.one/flowmade-one/iris/internal/element"
"git.flowmade.one/flowmade-one/iris/reactive"
)
// TextInput creates a reactive text input field
func TextInput(value *reactive.Signal[string], placeholder string) View {
input := element.NewElement("input")
input.Attr("type", "text")
input.Attr("placeholder", placeholder)
// Set initial value
input.Set("value", value.Get())
// Update input when signal changes
reactive.NewEffect(func() {
currentValue := value.Get()
if input.Get("value").String() != currentValue {
input.Set("value", currentValue)
}
})
// Update signal when user types
input.On("input", func() {
newValue := input.Get("value").String()
if value.Get() != newValue {
value.Set(newValue)
}
})
return View{input}
}
// TextArea creates a multi-line text input
func TextArea(value *reactive.Signal[string], placeholder string, rows int) View {
textarea := element.NewElement("textarea")
textarea.Attr("placeholder", placeholder)
textarea.Attr("rows", string(rune(rows)))
// Set initial value
textarea.Set("value", value.Get())
// Update textarea when signal changes
reactive.NewEffect(func() {
currentValue := value.Get()
if textarea.Get("value").String() != currentValue {
textarea.Set("value", currentValue)
}
})
// Update signal when user types
textarea.On("input", func() {
newValue := textarea.Get("value").String()
if value.Get() != newValue {
value.Set(newValue)
}
})
return View{textarea}
}
// Checkbox creates a reactive checkbox input
func Checkbox(checked *reactive.Signal[bool], label string) View {
container := element.NewElement("label")
container.Get("style").Call("setProperty", "display", "flex")
container.Get("style").Call("setProperty", "align-items", "center")
container.Get("style").Call("setProperty", "gap", "8px")
container.Get("style").Call("setProperty", "cursor", "pointer")
checkbox := element.NewElement("input")
checkbox.Attr("type", "checkbox")
// Set initial state
if checked.Get() {
checkbox.Set("checked", true)
}
// Update checkbox when signal changes
reactive.NewEffect(func() {
isChecked := checked.Get()
checkbox.Set("checked", isChecked)
})
// Update signal when user clicks
checkbox.On("change", func() {
newValue := checkbox.Get("checked").Bool()
checked.Set(newValue)
})
// Add label text
labelText := element.NewTextNode(label)
container.Child(checkbox)
container.Child(labelText)
return View{container}
}
// Select creates a dropdown selection component
func Select(selected *reactive.Signal[string], options []string, placeholder string) View {
selectElem := element.NewElement("select")
// Add placeholder option if provided
if placeholder != "" {
placeholderOption := element.NewElement("option")
placeholderOption.Set("value", "")
placeholderOption.Set("disabled", true)
placeholderOption.Set("selected", true)
placeholderOption.Set("textContent", placeholder)
selectElem.Child(placeholderOption)
}
// Add options
for _, option := range options {
optionElem := element.NewElement("option")
optionElem.Set("value", option)
optionElem.Set("textContent", option)
selectElem.Child(optionElem)
}
// Set initial value
selectElem.Set("value", selected.Get())
// Update select when signal changes
reactive.NewEffect(func() {
currentValue := selected.Get()
selectElem.Set("value", currentValue)
})
// Update signal when user selects
selectElem.On("change", func() {
newValue := selectElem.Get("value").String()
selected.Set(newValue)
})
return View{selectElem}
}
// Slider creates a range input for numeric values
func Slider(value *reactive.Signal[int], min, max int) View {
slider := element.NewElement("input")
slider.Attr("type", "range")
slider.Attr("min", strconv.Itoa(min))
slider.Attr("max", strconv.Itoa(max))
// Set initial value
slider.Set("value", strconv.Itoa(value.Get()))
// Update slider when signal changes
reactive.NewEffect(func() {
currentValue := value.Get()
slider.Set("value", strconv.Itoa(currentValue))
})
// Update signal when user drags
slider.On("input", func() {
newValueStr := slider.Get("value").String()
if newValue, err := strconv.Atoi(newValueStr); err == nil {
value.Set(newValue)
}
})
return View{slider}
}
// NumberInput creates a reactive numeric input field for float64 values
func NumberInput(value *reactive.Signal[float64], placeholder string) View {
input := element.NewElement("input")
input.Attr("type", "number")
input.Attr("step", "0.01")
input.Attr("min", "0")
input.Attr("placeholder", placeholder)
// Set initial value
if value.Get() != 0 {
input.Set("value", strconv.FormatFloat(value.Get(), 'f', -1, 64))
}
// Update input when signal changes
reactive.NewEffect(func() {
currentValue := value.Get()
var displayValue string
if currentValue == 0 {
displayValue = ""
} else {
displayValue = strconv.FormatFloat(currentValue, 'f', -1, 64)
}
if input.Get("value").String() != displayValue {
input.Set("value", displayValue)
}
})
// Update signal when user types
input.On("input", func() {
newValueStr := input.Get("value").String()
if newValueStr == "" {
value.Set(0.0)
} else if newValue, err := strconv.ParseFloat(newValueStr, 64); err == nil {
value.Set(newValue)
}
})
return View{input}
}

90
ui/layout.go Normal file
View File

@@ -0,0 +1,90 @@
package ui
import "strings"
// VerticalGroup is a view that arranges its children vertically
// It uses CSS Grid with a single column
func VerticalGroup(children ...View) View {
v := NewView()
v.e.Grid()
v.e.GridTemplateColumns("1fr") // Force single column
v.e.GridAutoFlow("row")
v.e.Width("100%")
for _, child := range children {
v.Child(child)
}
return v
}
// HorizontalGroup is a view that arranges its children horizontally
// It uses flexbox to do so
func HorizontalGroup(children ...View) View {
v := NewView()
v.e.Grid()
v.e.GridTemplateColumns("repeat(auto-fit, minmax(250px, 1fr))")
v.e.Width("100%")
for _, child := range children {
v.Child(child)
}
return v
}
// Spacer is a view that takes up all available space
// It uses flexbox to do so
func Spacer() View {
v := NewView()
v.e.Get("style").Call("setProperty", "flex", "1")
return v
}
// OverlayGroup creates a container where children are stacked on top of each other
func OverlayGroup(children ...View) View {
v := NewView()
v.e.Get("style").Call("setProperty", "position", "relative")
v.e.Get("style").Call("setProperty", "display", "block")
// Add children with absolute positioning (except first one)
for i, child := range children {
if i == 0 {
// First child acts as the base layer
v.Child(child)
} else {
// Subsequent children are absolutely positioned as overlays
// They need pointer-events: none so clicks pass through to layers below
child.e.Get("style").Call("setProperty", "position", "absolute")
child.e.Get("style").Call("setProperty", "top", "0")
child.e.Get("style").Call("setProperty", "left", "0")
child.e.Get("style").Call("setProperty", "width", "100%")
child.e.Get("style").Call("setProperty", "height", "100%")
child.e.Get("style").Call("setProperty", "pointer-events", "none")
v.Child(child)
}
}
return v
}
// Divider creates a visual separator line
func Divider() View {
v := NewView()
v.e.Get("style").Call("setProperty", "border-top", "1px solid #ccc")
v.e.Get("style").Call("setProperty", "margin", "8px 0")
v.e.Get("style").Call("setProperty", "width", "100%")
v.e.Get("style").Call("setProperty", "height", "1px")
return v
}
// FlexLayout creates a horizontal layout with explicit column sizing
// Example: FlexLayout([]string{"48px", "1fr", "240px"}, left, center, right)
// Supports fixed widths (e.g., "48px"), flexible ("1fr"), and auto-sized ("auto")
func FlexLayout(columns []string, children ...View) View {
v := NewView()
v.e.Grid()
v.e.Get("style").Call("setProperty", "grid-template-columns", strings.Join(columns, " "))
v.e.Width("100%")
v.e.Height("100%")
for _, child := range children {
v.Child(child)
}
return v
}

172
ui/modifiers.go Normal file
View File

@@ -0,0 +1,172 @@
package ui
func (v View) SpaceEvenly() View {
v.e.Get("style").Call("setProperty", "justify-content", "space-evenly")
return v
}
func (v View) SpaceBetween() View {
v.e.Get("style").Call("setProperty", "justify-content", "space-between")
return v
}
func (v View) AlignCenter() View {
v.e.Get("style").Call("setProperty", "align-items", "center")
return v
}
func (v View) AlignRight() View {
v.e.Get("style").Call("setProperty", "align-items", "flex-end")
return v
}
func (v View) AlignLeft() View {
v.e.Get("style").Call("setProperty", "align-items", "flex-start")
return v
}
func (v View) JustifyContent(justify string) View {
v.e.Get("style").Call("setProperty", "justify-content", justify)
return v
}
func (v View) Margin(margin string) View {
v.e.Get("style").Call("setProperty", "margin", margin)
return v
}
func (v View) Padding(padding string) View {
v.e.Get("style").Call("setProperty", "padding", padding)
return v
}
func (v View) Font(font Font) View {
v.e.Get("style").Call("setProperty", "font", font.String())
v.e.Get("style").Call("setProperty", "line-height", font.lineheight)
return v
}
func (v View) Color(color string) View {
v.e.Get("style").Call("setProperty", "color", color)
return v
}
func (v View) Gap(gap string) View {
v.e.Get("style").Call("setProperty", "gap", gap)
return v
}
func (v View) Border(border string) View {
v.e.Get("style").Call("setProperty", "border", border)
return v
}
func (v View) BorderTop(border string) View {
v.e.Get("style").Call("setProperty", "border-top", border)
return v
}
func (v View) BorderBottom(border string) View {
v.e.Get("style").Call("setProperty", "border-bottom", border)
return v
}
func (v View) BorderLeft(border string) View {
v.e.Get("style").Call("setProperty", "border-left", border)
return v
}
func (v View) BorderRight(border string) View {
v.e.Get("style").Call("setProperty", "border-right", border)
return v
}
func (v View) TextAlign(align string) View {
v.e.Get("style").Call("setProperty", "text-align", align)
return v
}
func (v View) MaxWidth(width string) View {
v.e.MaxWidth(width)
return v
}
func (v View) Width(width string) View {
v.e.Width(width)
return v
}
func (v View) Height(height string) View {
v.e.Height(height)
return v
}
func (v View) Background(color string) View {
v.e.BackgroundColor(color)
return v
}
func (v View) Foreground(color string) View {
v.e.Color(color)
return v
}
func (v View) MinHeight(height string) View {
v.e.MinHeight(height)
return v
}
func (v View) BoxShadow(shadow string) View {
v.e.BoxShadow(shadow)
return v
}
func (v View) BorderRadius(radius string) View {
v.e.BorderRadius(radius)
return v
}
func (v View) Cursor(cursor string) View {
v.e.Cursor(cursor)
return v
}
func (v View) Position(pos string) View {
v.e.Position(pos)
return v
}
func (v View) Left(value string) View {
v.e.Left(value)
return v
}
func (v View) Top(value string) View {
v.e.Top(value)
return v
}
func (v View) Overflow(value string) View {
v.e.Overflow(value)
return v
}
func (v View) Display(value string) View {
v.e.Get("style").Call("setProperty", "display", value)
return v
}
func (v View) GridTemplateColumns(value string) View {
v.e.Get("style").Call("setProperty", "grid-template-columns", value)
return v
}
func (v View) AlignItems(value string) View {
v.e.Get("style").Call("setProperty", "align-items", value)
return v
}
func (v View) PointerEvents(value string) View {
v.e.PointerEvents(value)
return v
}

264
ui/svg.go Normal file
View File

@@ -0,0 +1,264 @@
//go:build js && wasm
package ui
import (
"fmt"
"syscall/js"
"git.flowmade.one/flowmade-one/iris/internal/element"
)
const svgNS = "http://www.w3.org/2000/svg"
// SVG creates an SVG container element
func SVG() SVGElement {
e := element.NewElementNS(svgNS, "svg")
return SVGElement{e}
}
// SVGElement wraps an SVG element
type SVGElement struct {
e element.Element
}
// Attr sets an attribute on the SVG element
func (s SVGElement) Attr(name, value string) SVGElement {
s.e.Attr(name, value)
return s
}
// Width sets the width attribute
func (s SVGElement) Width(value string) SVGElement {
s.e.Attr("width", value)
return s
}
// Height sets the height attribute
func (s SVGElement) Height(value string) SVGElement {
s.e.Attr("height", value)
return s
}
// Position sets CSS position
func (s SVGElement) Position(pos string) SVGElement {
s.e.Position(pos)
return s
}
// Left sets CSS left
func (s SVGElement) Left(value string) SVGElement {
s.e.Left(value)
return s
}
// Top sets CSS top
func (s SVGElement) Top(value string) SVGElement {
s.e.Top(value)
return s
}
// PointerEvents sets CSS pointer-events
func (s SVGElement) PointerEvents(value string) SVGElement {
s.e.PointerEvents(value)
return s
}
// Child adds a child SVG element
func (s SVGElement) ChildSVG(child SVGElement) SVGElement {
s.e.Child(child.e)
return s
}
// ClearChildren removes all children
func (s SVGElement) ClearChildren() SVGElement {
s.e.ClearChildren()
return s
}
// ToView converts the SVG element to a View (for embedding in layouts)
func (s SVGElement) ToView() View {
return View{s.e}
}
// SVGDefs creates a defs element
func SVGDefs() SVGElement {
return SVGElement{element.NewElementNS(svgNS, "defs")}
}
// SVGMarker creates a marker element for arrowheads
func SVGMarker(id string) SVGElement {
e := element.NewElementNS(svgNS, "marker")
e.Attr("id", id)
return SVGElement{e}
}
// MarkerWidth sets marker width
func (s SVGElement) MarkerWidth(value string) SVGElement {
s.e.Attr("markerWidth", value)
return s
}
// MarkerHeight sets marker height
func (s SVGElement) MarkerHeight(value string) SVGElement {
s.e.Attr("markerHeight", value)
return s
}
// RefX sets refX
func (s SVGElement) RefX(value string) SVGElement {
s.e.Attr("refX", value)
return s
}
// RefY sets refY
func (s SVGElement) RefY(value string) SVGElement {
s.e.Attr("refY", value)
return s
}
// Orient sets orient
func (s SVGElement) Orient(value string) SVGElement {
s.e.Attr("orient", value)
return s
}
// MarkerUnits sets markerUnits
func (s SVGElement) MarkerUnits(value string) SVGElement {
s.e.Attr("markerUnits", value)
return s
}
// SVGPath creates a path element
func SVGPath(d string) SVGElement {
e := element.NewElementNS(svgNS, "path")
e.Attr("d", d)
return SVGElement{e}
}
// Fill sets fill color
func (s SVGElement) Fill(color string) SVGElement {
s.e.Attr("fill", color)
return s
}
// Stroke sets stroke color
func (s SVGElement) Stroke(color string) SVGElement {
s.e.Attr("stroke", color)
return s
}
// StrokeWidth sets stroke width
func (s SVGElement) StrokeWidth(value string) SVGElement {
s.e.Attr("stroke-width", value)
return s
}
// StrokeDasharray sets stroke dash pattern
func (s SVGElement) StrokeDasharray(value string) SVGElement {
s.e.Attr("stroke-dasharray", value)
return s
}
// MarkerEnd sets the end marker
func (s SVGElement) MarkerEnd(value string) SVGElement {
s.e.Attr("marker-end", value)
return s
}
// SVGLine creates a line element
func SVGLine(x1, y1, x2, y2 float64) SVGElement {
e := element.NewElementNS(svgNS, "line")
e.Attr("x1", fmt.Sprintf("%f", x1))
e.Attr("y1", fmt.Sprintf("%f", y1))
e.Attr("x2", fmt.Sprintf("%f", x2))
e.Attr("y2", fmt.Sprintf("%f", y2))
return SVGElement{e}
}
// SVGRect creates a rect element
func SVGRect(x, y, width, height float64) SVGElement {
e := element.NewElementNS(svgNS, "rect")
e.Attr("x", fmt.Sprintf("%f", x))
e.Attr("y", fmt.Sprintf("%f", y))
e.Attr("width", fmt.Sprintf("%f", width))
e.Attr("height", fmt.Sprintf("%f", height))
return SVGElement{e}
}
// SVGCircle creates a circle element
func SVGCircle(cx, cy, r float64) SVGElement {
e := element.NewElementNS(svgNS, "circle")
e.Attr("cx", fmt.Sprintf("%f", cx))
e.Attr("cy", fmt.Sprintf("%f", cy))
e.Attr("r", fmt.Sprintf("%f", r))
return SVGElement{e}
}
// SVGText creates a text element
func SVGText(text string, x, y float64) SVGElement {
e := element.NewElementNS(svgNS, "text")
e.Attr("x", fmt.Sprintf("%f", x))
e.Attr("y", fmt.Sprintf("%f", y))
e.Text(text)
return SVGElement{e}
}
// SVGGroup creates a g (group) element
func SVGGroup() SVGElement {
return SVGElement{element.NewElementNS(svgNS, "g")}
}
// OnClick adds a click handler to the SVG element
func (s SVGElement) OnClick(fn func()) SVGElement {
s.e.On("click", fn)
return s
}
// OnClickWithEvent adds a click handler with event access
func (s SVGElement) OnClickWithEvent(fn func(js.Value)) SVGElement {
s.e.OnWithEvent("click", fn)
return s
}
// DataAttr sets a data attribute (data-{name}="{value}")
func (s SVGElement) DataAttr(name, value string) SVGElement {
s.e.Attr("data-"+name, value)
return s
}
// ID sets the id attribute
func (s SVGElement) ID(id string) SVGElement {
s.e.Attr("id", id)
return s
}
// Cursor sets the CSS cursor property
func (s SVGElement) Cursor(value string) SVGElement {
s.e.Cursor(value)
return s
}
// FontSize sets the font-size attribute
func (s SVGElement) FontSize(value string) SVGElement {
s.e.Attr("font-size", value)
return s
}
// FontFamily sets the font-family attribute
func (s SVGElement) FontFamily(value string) SVGElement {
s.e.Attr("font-family", value)
return s
}
// TextAnchor sets the text-anchor attribute (start, middle, end)
func (s SVGElement) TextAnchor(value string) SVGElement {
s.e.Attr("text-anchor", value)
return s
}
// DominantBaseline sets the dominant-baseline attribute
func (s SVGElement) DominantBaseline(value string) SVGElement {
s.e.Attr("dominant-baseline", value)
return s
}

23
ui/text.go Normal file
View File

@@ -0,0 +1,23 @@
package ui
import (
"git.flowmade.one/flowmade-one/iris/internal/element"
"git.flowmade.one/flowmade-one/iris/reactive"
)
func TextFromString(text string) View {
v := View{element.NewElement("p")}
v.e.Set("textContent", text)
return v
}
func TextFromFunction(fn func() string) View {
textNode := element.NewElement("p")
reactive.NewEffect(func() {
value := fn()
textNode.Set("textContent", value)
})
return View{textNode}
}

10
ui/utils.go Normal file
View File

@@ -0,0 +1,10 @@
//go:build js && wasm
package ui
import "fmt"
// FormatPx formats a float as a CSS pixel value
func FormatPx(v float64) string {
return fmt.Sprintf("%.0fpx", v)
}

29
ui/view.go Normal file
View File

@@ -0,0 +1,29 @@
package ui
import "git.flowmade.one/flowmade-one/iris/internal/element"
type View struct {
e element.Element
}
func (v View) Child(child View) View {
v.e.Child(child.e)
return v
}
func NewView() View {
e := element.NewElement("div")
e.JustifyItems("center")
e.AlignItems("center")
return View{e}
}
// NewViewFromElement creates a View from an element (for advanced use cases)
func NewViewFromElement(e element.Element) View {
return View{e}
}
// Element returns the underlying element (for advanced use cases)
func (v View) Element() element.Element {
return v.e
}

37
vision.md Normal file
View File

@@ -0,0 +1,37 @@
# Iris Vision
WASM reactive UI framework for Go - build browser applications without JavaScript.
## Organization Context
This repo is part of Flowmade. See [organization manifesto](../architecture/manifesto.md) for who we are and what we believe.
## What This Is
Iris is an open-source UI framework that enables Go developers to build reactive browser applications compiled to WebAssembly. It provides:
- **Signals-based reactivity** - Automatic DOM updates when state changes
- **Component library** - Button, Text, Input, View, Canvas, SVG
- **Client-side routing** - Router with guards and history management
- **OIDC authentication** - Browser-based auth flows for WASM apps
- **Static file server** - Host component for serving WASM applications
## Who This Serves
- **Go developers** who want to build web UIs without learning JavaScript
- **Teams** building internal tools and dashboards
- **Projects** that benefit from shared Go code between server and client
## Goals
1. **Simple reactivity** - Signals that just work, no complex state management
2. **Familiar patterns** - Go idioms, not React/Vue patterns forced into Go
3. **WASM-first** - Designed for WebAssembly, not ported from elsewhere
4. **Minimal dependencies** - Only syscall/js for browser interop
## Non-Goals
- Server-side rendering (SSR)
- Virtual DOM diffing
- CSS-in-Go styling system
- Component marketplace