12 Commits

Author SHA1 Message Date
jkunz 87940efdef v1.4.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-02 18:54:34 +00:00
jkunz f08c4bfb7a feat(workspace-compose): add recipient routing roles and drag-and-drop routing management 2026-05-02 18:54:34 +00:00
jkunz f5cc69ed53 v1.3.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-02 18:37:48 +00:00
jkunz 57cbb739d2 feat(workspace): introduce a responsive signature workspace demo and remove legacy contract editor components 2026-05-02 18:37:48 +00:00
jkunz 90836f1c72 v1.2.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-18 17:46:16 +00:00
jkunz a9c2d2230c feat(icons): migrate icon usage to the new dees-icon API and integrate collaboration sidebar into the editor 2025-12-18 17:46:16 +00:00
jkunz 3d266c89b2 v1.1.0
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 17s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-18 15:27:22 +00:00
jkunz 56c087bc3a feat(catalog): add ContractEditor and many editor subcomponents; implement SignPad and SignBox; update README and bump dependencies 2025-12-18 15:27:22 +00:00
philkunz 6d53259b75 1.0.59
Default (tags) / security (push) Failing after 14s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2024-12-19 18:37:00 +01:00
philkunz eefcd4f807 fix(dependencies): Update package dependencies and project metadata 2024-12-19 18:37:00 +01:00
philkunz ce9db08ca0 1.0.58
Default (tags) / security (push) Failing after 15s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2023-11-28 20:44:46 +01:00
philkunz 247a401982 fix(core): update 2023-11-28 20:44:45 +01:00
28 changed files with 7945 additions and 3090 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
.nogit/ .nogit/
.playwright-mcp/
# artifacts # artifacts
coverage/ coverage/
@@ -17,4 +18,4 @@ node_modules/
dist/ dist/
dist_*/ dist_*/
# custom # custom
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+82
View File
@@ -0,0 +1,82 @@
{
"@git.zone/cli": {
"projectType": "wcc",
"module": {
"githost": "gitlab.com",
"gitscope": "signature.digital",
"gitrepo": "catalog",
"description": "A comprehensive catalog of customizable web components designed for building and managing e-signature applications.",
"npmPackagename": "@signature.digital/catalog",
"license": "MIT",
"projectDomain": "signature.digital",
"keywords": [
"e-signature",
"web components",
"digital signature",
"signature capture",
"ECMAScript Modules",
"typescript",
"component library",
"contract management",
"frontend development",
"signature pad",
"custom elements",
"electronic signing",
"npm package"
]
},
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
}
},
"@git.zone/tsbundle": {
"bundles": [
{
"from": "./html/index.ts",
"to": "./dist_bundle/bundle.js",
"outputMode": "bundle",
"bundler": "esbuild",
"production": true,
"includeFiles": [
"./html/index.html"
]
}
]
},
"@git.zone/tswatch": {
"server": {
"enabled": true,
"port": 3002,
"serveDir": "./dist_watch/",
"liveReload": true
},
"bundles": [
{
"name": "element-bundle",
"from": "./html/index.ts",
"to": "./dist_watch/bundle.js",
"watchPatterns": [
"./ts_web/**/*",
"./html/index.ts"
],
"triggerReload": true,
"bundler": "esbuild",
"production": false
},
{
"name": "html",
"from": "./html/index.html",
"to": "./dist_watch/index.html",
"watchPatterns": [
"./html/**/*"
],
"triggerReload": true
}
]
},
"@ship.zone/szci": {}
}
+49
View File
@@ -0,0 +1,49 @@
# Changelog
## 2026-05-02 - 1.4.0 - feat(workspace-compose)
add recipient routing roles and drag-and-drop routing management
- Introduce recipient roles for signers, final-copy recipients, and step-update recipients.
- Replace simple recipient reordering with role-based drag-and-drop routing sections and contextual role assignment.
- Limit field assignment and active field tools to signing recipients, with automatic field reassignment when a signer is moved out of the signing flow.
## 2026-05-02 - 1.3.0 - feat(workspace)
introduce a responsive signature workspace demo and remove legacy contract editor components
- Adds a new `sdig-workspace` component with inbox, compose, sign, audit, developers, templates, team, and settings views.
- Removes the previous contract editor module and related contract subcomponents from exports and source files.
- Updates package exports, build configuration, and project metadata for the new workspace-focused catalog distribution.
- Improves sign pad and sign box null-safety and groups demos under signature primitives and workspace categories.
## 2025-12-18 - 1.2.0 - feat(icons)
migrate icon usage to the new dees-icon API and integrate collaboration sidebar into the editor
- Replaced deprecated .iconFA with .icon across multiple components
- Updated lucide icon identifiers to PascalCase/camelCase to match new dees-icon format
- Added sdig-collaboration-sidebar component and exported it from elements index
- Integrated a toggleable editor sidebar (PanelRight) and wired comment/suggestion navigation & add-comment events in sdig-contracteditor
- Added development hints (readme.hints.md) documenting dees-icon usage and icon name formats
- Minor UI/styling tweak: .btn-ghost.active appearance
## 2025-12-18 - 1.1.0 - feat(catalog)
add ContractEditor and many editor subcomponents; implement SignPad and SignBox; update README and bump dependencies
- New contract editor module with many subcomponents added (header, metadata, parties, content, terms, signatures, attachments, collaboration, audit)
- Implemented signature UI components: sdig-signpad and sdig-signbox (canvas-based signature capture, undo/clear/export APIs)
- Reorganized exports (ts_web/elements/index.ts) to expose new submodules and barrel files
- Added editor types and smartstate store (ts_web/elements/sdig-contracteditor/types.ts and state.ts) for editor state management, undo/redo and events
- Large README overhaul: improved usage examples, API docs, security/issue reporting section, theming and integration examples
- Dependencies bumped in package.json (notable bumps: @design.estate/* packages, @git.zone tooling, signature_pad) and pnpm overrides added
- tsconfig.json compiler options modified (removed experimentalDecorators and useDefineForClassFields) — may affect local build configurations
## 2024-12-19 - 1.0.59 - fix(dependencies)
Update package dependencies and project metadata
- Updated package dependencies to the latest versions in package.json
- Synchronized project description and keywords in npmextra.json with package.json
## 2023-11-28 - 1.0.55 to 1.0.58 - core updates
Main changes include fixing and updating core functionalities.
- Fixed core issues and updated core functionalities in versions 1.0.55, 1.0.56, and 1.0.57.
- Further updates and improvements were carried out in version 1.0.58.
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) 2014 Task Venture Capital GmbH (hello@task.vc)
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.
-18
View File
@@ -1,18 +0,0 @@
{
"gitzone": {
"projectType": "wcc",
"module": {
"githost": "gitlab.com",
"gitscope": "signature.digital_private",
"gitrepo": "catalog",
"description": "a catalog containing components for e-signing",
"npmPackagename": "@signature.digital_private/catalog",
"license": "UNLICENSED",
"projectDomain": "signature.digital"
}
},
"npmci": {
"npmGlobalTools": [],
"npmAccessLevel": "private"
}
}
+38 -22
View File
@@ -1,33 +1,33 @@
{ {
"name": "@signature.digital_private/catalog", "name": "@signature.digital/catalog",
"version": "1.0.57", "version": "1.4.0",
"private": false, "private": false,
"description": "a catalog containing components for e-signing", "description": "A comprehensive catalog of customizable web components designed for building and managing e-signature applications.",
"main": "dist_ts_web/index.js", "exports": {
"typings": "dist_ts_web/index.d.ts", ".": "./dist_ts_web/index.js"
},
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "npm run build", "test": "pnpm run build",
"build": "tsbuild element && tsbundle element --production", "build": "tsbuild tsfolders --allowimplicitany && tsbundle",
"watch": "tswatch element" "watch": "tswatch"
}, },
"author": "Task Venture Capital GmbH", "author": "Task Venture Capital GmbH",
"license": "UNLICENSED", "license": "MIT",
"dependencies": { "dependencies": {
"@design.estate/dees-catalog": "^1.0.230", "@design.estate/dees-catalog": "3.81.0",
"@design.estate/dees-domtools": "^2.0.55", "@design.estate/dees-domtools": "^2.5.6",
"@design.estate/dees-element": "^2.0.33", "@design.estate/dees-element": "^2.2.4",
"@design.estate/dees-wcctools": "^1.0.37", "@design.estate/dees-wcctools": "^3.9.0",
"@git.zone/tsrun": "^1.2.12", "@git.zone/tsrun": "^2.0.3",
"@losslessone_private/loint-pubapi": "^1.0.9", "signature_pad": "^5.1.3"
"@signature.digital/portablecontract": "^1.0.3",
"signature_pad": "^4.1.7"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^2.1.24", "@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsbundle": "^2.0.10", "@git.zone/tsbundle": "2.10.1",
"@git.zone/tswatch": "^2.0.13", "@git.zone/tswatch": "^3.3.3",
"@push.rocks/projectinfo": "^5.0.2" "@push.rocks/projectinfo": "^5.1.0",
"@types/node": "^25.6.0"
}, },
"files": [ "files": [
"ts/**/*", "ts/**/*",
@@ -38,10 +38,26 @@
"dist_ts_web/**/*", "dist_ts_web/**/*",
"assets/**/*", "assets/**/*",
"cli.js", "cli.js",
"npmextra.json", ".smartconfig.json",
"license",
"readme.md" "readme.md"
], ],
"browserslist": [ "browserslist": [
"last 1 Chrome versions" "last 1 Chrome versions"
],
"keywords": [
"e-signature",
"web components",
"digital signature",
"signature capture",
"ECMAScript Modules",
"typescript",
"component library",
"contract management",
"frontend development",
"signature pad",
"custom elements",
"electronic signing",
"npm package"
] ]
} }
+5990 -2966
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
# Development Hints for @signature.digital/catalog
## dees-icon Usage
**Important**: Use the `.icon` property, NOT `.iconFA` (deprecated).
### Format
```html
<dees-icon .icon=${'prefix:IconName'}></dees-icon>
```
### Lucide Icons (PascalCase)
Lucide icons use **PascalCase** names:
- `lucide:CheckCircle`
- `lucide:UserPlus`
- `lucide:PenTool`
- `lucide:Mail`
- `lucide:Users`
**Wrong formats**:
- `lucide:check-circle` ✗ (kebab-case doesn't work)
- `lucide:userPlus` ✗ (camelCase doesn't work)
### FontAwesome Icons (camelCase)
FontAwesome icons use **camelCase** names:
- `fa:arrowRight`
- `fa:magnifyingGlass`
- `fa:penToSquare`
### Documentation
See: https://code.foss.global/design.estate/dees-catalog/src/branch/main/readme.icons.md
+167 -26
View File
@@ -1,31 +1,172 @@
# @signature.digital_private/catalog # @signature.digital/catalog
a catalog containing components for e-signing
## Availabililty and Links Reusable web components for signature.digital product surfaces. The package ships signature capture primitives, a complete signature box, and a responsive workspace shell built with Lit and `@design.estate/dees-element`.
* [npmjs.org (npm package)](https://www.npmjs.com/package/@signature.digital_private/catalog)
* [gitlab.com (source)](https://gitlab.com/signature.digital_private/catalog)
* [github.com (source mirror)](https://github.com/signature.digital_private/catalog)
* [docs (typedoc)](https://signature.digital_private.gitlab.io/catalog/)
## Status for master Use it when you need browser-native custom elements for signing flows without pulling product-specific authentication, persistence, routing, or API behavior into the component layer.
Status Category | Status Badge ## Issue Reporting and Security
-- | --
GitLab Pipelines | [![pipeline status](https://gitlab.com/signature.digital_private/catalog/badges/master/pipeline.svg)](https://lossless.cloud)
GitLab Pipline Test Coverage | [![coverage report](https://gitlab.com/signature.digital_private/catalog/badges/master/coverage.svg)](https://lossless.cloud)
npm | [![npm downloads per month](https://badgen.net/npm/dy/@signature.digital_private/catalog)](https://lossless.cloud)
Snyk | [![Known Vulnerabilities](https://badgen.net/snyk/signature.digital_private/catalog)](https://lossless.cloud)
TypeScript Support | [![TypeScript](https://badgen.net/badge/TypeScript/>=%203.x/blue?icon=typescript)](https://lossless.cloud)
node Support | [![node](https://img.shields.io/badge/node->=%2010.x.x-blue.svg)](https://nodejs.org/dist/latest-v10.x/docs/api/)
Code Style | [![Code Style](https://badgen.net/badge/style/prettier/purple)](https://lossless.cloud)
PackagePhobia (total standalone install weight) | [![PackagePhobia](https://badgen.net/packagephobia/install/@signature.digital_private/catalog)](https://lossless.cloud)
PackagePhobia (package size on registry) | [![PackagePhobia](https://badgen.net/packagephobia/publish/@signature.digital_private/catalog)](https://lossless.cloud)
BundlePhobia (total size when bundled) | [![BundlePhobia](https://badgen.net/bundlephobia/minzip/@signature.digital_private/catalog)](https://lossless.cloud)
## Usage For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
Use TypeScript for best in class intellisense
For further information read the linked docs at the top of this readme.
## Legal ## Install
> UNLICENSED licensed | **&copy;** [Task Venture Capital GmbH](https://task.vc)
| By using this npm module you agree to our [privacy policy](https://lossless.gmbH/privacy) ```shell
pnpm add @signature.digital/catalog
```
## Quick Start
Register all elements once in your browser bundle:
```typescript
import '@signature.digital/catalog';
```
Render the product workspace:
```html
<sdig-workspace accent="#3b82f6" density="comfortable" theme="dark"></sdig-workspace>
```
Render a standalone signature box:
```html
<sdig-signbox></sdig-signbox>
```
## Components
| Component | Tag | Purpose |
|-----------|-----|---------|
| `SdigWorkspace` | `<sdig-workspace>` | Product workspace shell with inbox, compose, signing, audit, developer, template, team, and settings views |
| `SignBox` | `<sdig-signbox>` | Complete signature capture UI with clear, undo, and submit actions |
| `SignPad` | `<sdig-signpad>` | Canvas-based signature capture primitive backed by `signature_pad` |
## Workspace Shell
`sdig-workspace` is the full product surface. It is responsive, supports dark/light themes, compact/comfortable density, an accent color, and a configurable initial view.
```html
<sdig-workspace
accent="#7c3aed"
density="compact"
theme="light"
initialView="compose"
></sdig-workspace>
```
### Workspace Properties
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `accent` | `string` | `#3b82f6` | Accent color used for active navigation and highlights |
| `density` | `'compact' \| 'comfortable'` | `comfortable` | Controls spacing density |
| `theme` | `'dark' \| 'light'` | `dark` | Reflected to the host and used for design tokens |
| `initialView` | `TWorkspaceView` | `inbox` | First view displayed after connection |
### Workspace Views
| View | Notes |
|------|-------|
| `inbox` | Demo document inbox with status, recipients, sender, page count, and deadlines |
| `compose` | Document preparation surface with recipients and draggable-style field placements |
| `sign` | Signing flow surface for reviewing and completing assigned fields |
| `audit` | Audit trail surface for signature and document events |
| `developers` | Developer-facing integration and API concept surface |
| `templates` | Placeholder for reusable agreement templates |
| `team` | Placeholder for workspace members and roles |
| `settings` | Placeholder for workspace, billing, and security settings |
### Workspace Events
```typescript
const workspace = document.querySelector('sdig-workspace');
workspace?.addEventListener('view-change', (event) => {
const { view } = (event as CustomEvent<{ view: string }>).detail;
console.log(view);
});
```
Child views can request navigation by dispatching `workspace-view-request` with `{ view }` in `detail`. The event bubbles and crosses the shadow boundary.
## Signature Box
Use `sdig-signbox` when you want the default capture UI with a heading, a signature pad, and built-in action controls:
```html
<sdig-signbox></sdig-signbox>
```
```typescript
const signbox = document.querySelector('sdig-signbox');
signbox?.addEventListener('signature', (event) => {
const { signature } = (event as CustomEvent<{ signature: unknown[] }>).detail;
console.log(signature);
});
```
The `signature` event detail contains the `signature_pad` stroke data returned by `sdig-signpad.toData()`.
## Signature Pad
Use `sdig-signpad` directly when you need lower-level control over the canvas:
```html
<sdig-signpad></sdig-signpad>
```
```typescript
const signpad = document.querySelector('sdig-signpad');
const strokes = await signpad?.toData();
const svg = await signpad?.toSVG();
await signpad?.undo();
await signpad?.clear();
```
| Method | Description |
|--------|-------------|
| `toData()` | Returns `signature_pad` stroke data |
| `fromData(data)` | Restores `signature_pad` stroke data |
| `toSVG()` | Returns an SVG representation without a background color |
| `undo()` | Removes the latest stroke |
| `clear()` | Clears the canvas |
## Demo Data Boundary
The workspace currently renders local demo UI data from `sdig-workspace.shared.ts`. Real accounts, server persistence, document import/export, notification delivery, authentication, and legal signing workflow state belong in `@signature.digital/app` or backend services, not in this component catalog.
## Development
```shell
pnpm install
pnpm run build
pnpm test
pnpm run watch
```
The build compiles `ts_web/` into `dist_ts_web/` and bundles the component catalog demo from `html/index.ts` into `dist_bundle/`. Watch mode serves `dist_watch/` using the `.smartconfig.json` `@git.zone/tswatch` configuration.
## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
### Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
+4 -4
View File
@@ -1,8 +1,8 @@
/** /**
* autocreated commitinfo by @pushrocks/commitinfo * autocreated commitinfo by @push.rocks/commitinfo
*/ */
export const commitinfo = { export const commitinfo = {
name: '@signature.digital_private/catalog', name: '@signature.digital/catalog',
version: '1.0.57', version: '1.4.0',
description: 'a catalog containing components for e-signing' description: 'A comprehensive catalog of customizable web components designed for building and managing e-signature applications.'
} }
+6 -3
View File
@@ -1,3 +1,6 @@
export * from './sdig-contracteditor.js'; // Signature components
export * from './sdig-signbox.js'; export * from './sdig-signbox/index.js';
export * from './sdig-signpad.js'; export * from './sdig-signpad/index.js';
// Product workspace component
export * from './sdig-workspace/index.js';
-11
View File
@@ -1,11 +0,0 @@
import { DeesElement, property, html, customElement, type TemplateResult, css, cssManager } from '@design.estate/dees-element';
import * as plugins from '../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contracteditor': ContractEditor;
}
}
@customElement('sdig-signbox')
export class ContractEditor extends DeesElement {}
+1
View File
@@ -0,0 +1 @@
export * from './sdig-signbox.js';
@@ -1,5 +1,4 @@
import { DeesElement, property, html, customElement, type TemplateResult, css, cssManager } from '@design.estate/dees-element'; import { DeesElement, html, customElement, type TemplateResult, css, cssManager } from '@design.estate/dees-element';
import * as plugins from '../plugins.js';
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
@@ -12,6 +11,7 @@ export class SignBox extends DeesElement {
public static demo = () => html` public static demo = () => html`
<sdig-signbox></sdig-signbox> <sdig-signbox></sdig-signbox>
`; `;
public static demoGroups = ['Signature Digital Primitives'];
constructor() { constructor() {
super(); super();
@@ -80,17 +80,19 @@ export class SignBox extends DeesElement {
<sdig-signpad></sdig-signpad> <sdig-signpad></sdig-signpad>
<div class="actions"> <div class="actions">
<div class="button" @click=${async () => { <div class="button" @click=${async () => {
await this.shadowRoot.querySelector('sdig-signpad').clear(); await this.shadowRoot?.querySelector('sdig-signpad')?.clear();
}}> }}>
Clear Clear
</div> </div>
<div class="button" @click=${async () => { <div class="button" @click=${async () => {
await this.shadowRoot.querySelector('sdig-signpad').undo(); await this.shadowRoot?.querySelector('sdig-signpad')?.undo();
}}> }}>
Undo Undo
</div> </div>
<div class="button" @click=${async () => { <div class="button" @click=${async () => {
const signature = await this.shadowRoot.querySelector('sdig-signpad').toData(); const signaturePad = this.shadowRoot?.querySelector('sdig-signpad');
if (!signaturePad) return;
const signature = await signaturePad.toData();
this.dispatchEvent(new CustomEvent('signature', { this.dispatchEvent(new CustomEvent('signature', {
detail: { detail: {
signature, signature,
@@ -104,4 +106,4 @@ export class SignBox extends DeesElement {
</div> </div>
`; `;
} }
} }
+1
View File
@@ -0,0 +1 @@
export * from './sdig-signpad.js';
@@ -1,5 +1,5 @@
import { DeesElement, property, html, customElement, type TemplateResult, css, cssManager } from '@design.estate/dees-element'; import { DeesElement, html, customElement, type TemplateResult, css, cssManager } from '@design.estate/dees-element';
import * as plugins from '../plugins.js'; import * as plugins from '../../plugins.js';
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
@@ -12,6 +12,7 @@ export class SignPad extends DeesElement {
public static demo = () => html` public static demo = () => html`
<sdig-signpad></sdig-signpad> <sdig-signpad></sdig-signpad>
`; `;
public static demoGroups = ['Signature Digital Primitives'];
constructor() { constructor() {
@@ -26,13 +27,14 @@ export class SignPad extends DeesElement {
color: white; color: white;
position: relative; position: relative;
max-width: 600px; max-width: 600px;
min-height: 200px; min-height: 280px;
max-height: 400px; max-height: 400px;
} }
.mainbox { .mainbox {
position: relative; position: relative;
min-height: 280px; width: 600px;
height: 280px;
} }
.signline { .signline {
@@ -58,11 +60,14 @@ export class SignPad extends DeesElement {
`; `;
} }
public signaturePad: typeof plugins.signaturePad.prototype; public signaturePad?: typeof plugins.signaturePad.prototype;
public async firstUpdated() { public async firstUpdated() {
const domtools = await this.domtoolsPromise; await this.domtoolsPromise;
const mainbox = this.shadowRoot?.querySelector('.mainbox');
if (!mainbox) return;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
this.shadowRoot.querySelector('.mainbox').appendChild(canvas); mainbox.appendChild(canvas);
await this.resizeCanvas(); await this.resizeCanvas();
this.signaturePad = new plugins.signaturePad(canvas, { this.signaturePad = new plugins.signaturePad(canvas, {
@@ -71,10 +76,11 @@ export class SignPad extends DeesElement {
} }
public async resizeCanvas() { public async resizeCanvas() {
const mainbox = this.shadowRoot.querySelector('.mainbox'); const mainbox = this.shadowRoot?.querySelector('.mainbox');
const canvas = this.shadowRoot?.querySelector('canvas');
if (!mainbox || !canvas) return;
const mainboxWidth = mainbox.clientWidth; const mainboxWidth = mainbox.clientWidth;
const mainboxHeight = mainbox.clientHeight; const mainboxHeight = mainbox.clientHeight;
const canvas = this.shadowRoot.querySelector('canvas');
canvas.width = mainboxWidth; canvas.width = mainboxWidth;
canvas.height = mainboxHeight; canvas.height = mainboxHeight;
if (this.signaturePad) { if (this.signaturePad) {
@@ -83,22 +89,22 @@ export class SignPad extends DeesElement {
} }
public async clear() { public async clear() {
this.signaturePad.clear(); this.signaturePad?.clear();
} }
public async toData() { public async toData() {
const returnData = this.signaturePad.toData(); const returnData = this.signaturePad?.toData() || [];
return returnData; return returnData;
} }
public async fromData(dataArrayArg: any[]) { public async fromData(dataArrayArg: any[]) {
this.signaturePad.fromData(dataArrayArg); this.signaturePad?.fromData(dataArrayArg);
} }
public async toSVG() { public async toSVG() {
return this.signaturePad.toSVG({ return this.signaturePad?.toSVG({
includeBackgroundColor: false, includeBackgroundColor: false,
}); }) || '';
} }
public async undo() { public async undo() {
@@ -106,4 +112,4 @@ export class SignPad extends DeesElement {
data.pop(); data.pop();
await this.fromData(data); await this.fromData(data);
} }
} }
+8
View File
@@ -0,0 +1,8 @@
export * from './sdig-workspace.shared.js';
export * from './sdig-workspace-inbox.js';
export * from './sdig-workspace-compose.js';
export * from './sdig-workspace-sign.js';
export * from './sdig-workspace-audit.js';
export * from './sdig-workspace-developers.js';
export * from './sdig-workspace-placeholder.js';
export * from './sdig-workspace.js';
@@ -0,0 +1,37 @@
import { DeesElement, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
import { actionButton, demoRecipients, icon, pill, topBar, workspaceBaseStyles, workspaceDemoFrame } from './sdig-workspace.shared.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-workspace-audit': SdigWorkspaceAudit;
}
}
@customElement('sdig-workspace-audit')
export class SdigWorkspaceAudit extends DeesElement {
public static demo = () => workspaceDemoFrame(html`<sdig-workspace-audit></sdig-workspace-audit>`);
public static demoGroups = ['Signature Digital Workspace'];
public static styles = [workspaceBaseStyles, css`
.audit-grid { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 20px; }
.event-row { display: grid; grid-template-columns: 24px 180px 1fr 200px; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--border-subtle); align-items: center; }
@media (max-width: 920px) { .audit-grid { grid-template-columns: 1fr; } .event-row { grid-template-columns: 24px 1fr; } .event-row .hide-mobile { display: none; } }
`];
public render(): TemplateResult {
const events = [
['2026-05-02 14:32:18 UTC', 'Sarah Chen', 'Document signed', '81.221.4.18 · Brussels, BE', '0x4a7b…f29c', 'success'],
['2026-05-02 14:31:54 UTC', 'Sarah Chen', 'Signature adopted (typed)', '81.221.4.18 · Brussels, BE', '0x4a7b…f29c', 'info'],
['2026-05-02 14:28:02 UTC', 'Sarah Chen', 'Document opened', '81.221.4.18 · Brussels, BE', '', 'default'],
['2026-05-02 11:02:11 UTC', 'Philipp K.', 'Document sent for signature', '92.42.114.7 · Berlin, DE', '0x1c8a…3b6f', 'info'],
['2026-05-02 10:54:22 UTC', 'Philipp K.', 'Document created', '92.42.114.7 · Berlin, DE', '0x1c8a…3b6f', 'default'],
];
return html`
${topBar({ breadcrumb: ['signature.digital', 'Inbox', 'doc_8mK3pL', 'Audit Trail'], title: 'Audit Trail', subtitle: pill('completed · cryptographically sealed', 'success', true), actions: html`${actionButton('Certificate (PDF)', 'outline', 'download')}${actionButton('Verify on chain', 'outline', 'hash')}` })}
<div class="content-scroll audit-grid">
<div class="card"><div style="height: 36px; padding: 0 16px; border-bottom: 1px solid var(--border-subtle); display: flex; align-items: center; justify-content: space-between;"><span style="font-size: 12px; font-weight: 600;">Event log</span><span class="mono" style="font-size: 10px; color: var(--text-muted);">${events.length} events · immutable</span></div>${events.map((event) => html`<div class="event-row"><div><span style="display: block; width: 8px; height: 8px; border-radius: 50%; background: ${event[5] === 'success' ? 'var(--success)' : event[5] === 'info' ? 'var(--accent)' : 'var(--text-dim)'};"></span></div><div class="mono hide-mobile" style="font-size: 11px; color: var(--text-muted);">${event[0]}</div><div><div style="font-size: 12px; font-weight: 500;">${event[2]}</div><div style="font-size: 10px; color: var(--text-muted); margin-top: 2px;">by ${event[1]} ${event[4] ? html`<span class="mono" style="color: var(--accent); margin-left: 8px;">${event[4]}</span>` : ''}</div></div><div class="mono hide-mobile" style="font-size: 10px; color: var(--text-muted); text-align: right;">${event[3]}</div></div>`)}</div>
<div style="display: flex; flex-direction: column; gap: 16px;"><div class="card" style="padding: 16px;"><div class="label-upper">Document hash</div><div class="mono" style="font-size: 11px; color: var(--accent); word-break: break-all; line-height: 1.5; padding: 10px; background: var(--bg-el); border-radius: 4px; border: 1px solid var(--border-subtle);">0x4a7b8f29c91e3d2a5b6c8e0f1d3c5a7b9d2e4f6a8c1e3d5f7b9c1e3a5b7d9f0e</div></div><div class="card" style="padding: 16px;"><div class="label-upper">Signers</div>${demoRecipients.map((recipient) => html`<div class="recipient-line"><span class="avatar" style="background: ${recipient.color};">${recipient.name.split(' ').map((part) => part[0]).slice(0, 2).join('')}</span><div style="flex: 1;"><div style="font-size: 12px;">${recipient.name}</div><div class="mono" style="font-size: 10px; color: var(--text-muted);">${recipient.email}</div></div>${icon('check', 12)}</div>`)}</div><div class="card" style="padding: 16px; border-color: rgba(34,197,94,0.2);"><div style="display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--success); margin-bottom: 6px;">${icon('shield', 13)} eIDAS Qualified · ESIGN Act compliant</div><div style="font-size: 11px; color: var(--text-muted); line-height: 1.55;">Open-source verifier available. Anyone can independently validate this signature against the public ledger.</div></div></div>
</div>
`;
}
}
@@ -0,0 +1,583 @@
import { DeesElement, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
import { actionButton, demoFields, demoRecipients, fakeDocument, icon, pill, topBar, workspaceBaseStyles, workspaceDemoFrame, type IFieldPlacement, type IRecipient, type TRecipientRole } from './sdig-workspace.shared.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-workspace-compose': SdigWorkspaceCompose;
}
}
type TFieldDefinition = {
type: IFieldPlacement['type'];
icon: string;
label: string;
w: number;
h: number;
};
type TResizeHandle = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw';
type TFieldInteraction = {
fieldId: string;
mode: 'move' | 'resize';
handle?: TResizeHandle;
startClientX: number;
startClientY: number;
startField: IFieldPlacement;
pageWidth: number;
pageHeight: number;
};
type TSigningOrderDrag = {
recipientId: number;
pointerY: number;
listTop: number;
grabOffsetY: number;
itemHeight: number;
itemStep: number;
targetRole: TRecipientRole;
targetIndex: number;
};
type TRecipientContextMenu = {
recipientId: number;
x: number;
y: number;
};
const fieldDefinitions: TFieldDefinition[] = [
{ type: 'signature', icon: 'sign', label: 'Signature', w: 200, h: 50 },
{ type: 'initials', icon: 'type', label: 'Initials', w: 120, h: 32 },
{ type: 'date', icon: 'calendar', label: 'Date', w: 120, h: 32 },
{ type: 'text', icon: 'type', label: 'Text field', w: 220, h: 32 },
{ type: 'check', icon: 'check', label: 'Checkbox', w: 120, h: 32 },
];
const resizeHandles: TResizeHandle[] = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'];
const recipientRoleDefinitions: Array<{ role: TRecipientRole; label: string; shortLabel: string; description: string }> = [
{ role: 'signer', label: 'Needs signature', shortLabel: 'Signer', description: 'Can receive fields and must sign in order.' },
{ role: 'copy', label: 'Final copy only', shortLabel: 'Copy', description: 'Receives the completed document after signing.' },
{ role: 'updates', label: 'Every step update', shortLabel: 'Updates', description: 'Receives notifications for every routing step.' },
];
@customElement('sdig-workspace-compose')
export class SdigWorkspaceCompose extends DeesElement {
public static demo = () => workspaceDemoFrame(html`<sdig-workspace-compose></sdig-workspace-compose>`);
public static demoGroups = ['Signature Digital Workspace'];
@state() private accessor step: number = 2;
@state() private accessor activeRecipient: number = 0;
@state() private accessor selectedFieldId: string | null = null;
@state() private accessor recipients: IRecipient[] = [...demoRecipients];
@state() private accessor fields: IFieldPlacement[] = [...demoFields];
@state() private accessor signingOrderDrag: TSigningOrderDrag | null = null;
@state() private accessor recipientContextMenu: TRecipientContextMenu | null = null;
private draggedFieldDefinition: TFieldDefinition | null = null;
private draggedFieldGrabOffset: { x: number; y: number } | null = null;
private fieldInteraction: TFieldInteraction | null = null;
public static styles = [workspaceBaseStyles, css`
.stepper { height: 44px; flex-shrink: 0; border-bottom: 1px solid var(--border-subtle); display: flex; align-items: center; padding: 0 24px; gap: 24px; overflow-x: auto; }
.step { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-muted); white-space: nowrap; background: transparent; }
.step-number { width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: var(--bg-input); border: 1px solid var(--border); color: var(--text-muted); font-size: 10px; font-weight: 700; }
.step.active { color: var(--text); font-weight: 500; }
.step.active .step-number { background: var(--accent); border-color: var(--accent); color: white; }
.step.done .step-number { background: var(--success); border-color: var(--success); color: white; }
.compose-workspace { flex: 1; display: flex; overflow: hidden; }
.palette { width: 260px; border-right: 1px solid var(--border-subtle); padding: 16px; background: var(--bg-el); overflow-y: auto; flex-shrink: 0; }
.right-panel { width: 280px; border-left: 1px solid var(--border-subtle); padding: 16px; background: var(--bg-el); overflow-y: auto; flex-shrink: 0; }
.field-tool { width: var(--tool-w); height: var(--tool-h); display: flex; align-items: center; gap: 8px; padding: 0 10px; background: color-mix(in srgb, var(--recipient-color) 8%, var(--bg-card)); border: 1.5px dashed var(--recipient-color); border-radius: 4px; font-size: 12px; color: var(--recipient-color); margin-bottom: 8px; cursor: grab; }
.field-tool:active { cursor: grabbing; }
.field-tool span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.swatch { width: 10px; height: 10px; border-radius: 2px; background: var(--recipient-color, var(--accent)); flex-shrink: 0; }
.document-stage { flex: 1; overflow: auto; background: hsl(0 0% 8%); display: flex; flex-direction: column; align-items: center; padding: 32px; gap: 20px; }
:host-context(sdig-workspace[theme='light']) .document-stage { background: hsl(0 0% 92%); }
.recipient-line { cursor: grab; }
.routing-role-section { margin-bottom: 14px; padding: 10px; border: 1px solid var(--border-subtle); border-radius: 8px; background: color-mix(in srgb, var(--bg-card) 72%, transparent); transition: border-color 0.14s ease, background 0.14s ease; }
.routing-role-section.active-drop { border-color: color-mix(in srgb, var(--accent) 48%, var(--border)); background: color-mix(in srgb, var(--accent) 7%, var(--bg-card)); }
.routing-role-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
.routing-role-title { font-size: 11px; font-weight: 700; color: var(--text-sec); }
.routing-role-description { margin-bottom: 8px; font-size: 10px; line-height: 1.35; color: var(--text-muted); }
.role-count { min-width: 18px; height: 18px; padding: 0 6px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; background: var(--bg-input); color: var(--text-muted); font-size: 10px; }
.role-chip { height: 22px; padding: 0 7px; border-radius: 999px; display: inline-flex; align-items: center; background: var(--bg-input); color: var(--text-muted); font-size: 10px; font-weight: 600; }
.signing-order-list { position: relative; min-height: 44px; }
.signing-order-list.dragging { height: var(--routing-list-height); min-height: var(--routing-list-height); }
.signing-order-list::before { content: ''; position: absolute; left: 11px; top: 10px; bottom: 10px; width: 1px; background: var(--border); }
.signing-recipient { position: relative; z-index: 1; transition: transform 0.14s ease, opacity 0.14s ease, border-color 0.14s ease; }
.signing-order-list.dragging .signing-recipient:not(.signing-drag-overlay) { position: absolute; left: 0; right: 0; top: var(--routing-top); margin-bottom: 0; transition: top 0.16s ease, transform 0.14s ease, opacity 0.14s ease, border-color 0.14s ease; }
.signing-placeholder { position: absolute; left: 0; right: 0; top: var(--routing-top); height: var(--routing-row-height); border: 1.5px dashed var(--accent); border-radius: 6px; background: transparent; pointer-events: none; transition: top 0.16s ease; }
.signing-drag-overlay { position: absolute; left: 0; right: 0; z-index: 6; top: var(--routing-top); margin-bottom: 0; cursor: grabbing; pointer-events: none; border-color: var(--accent); box-shadow: 0 10px 28px rgba(0,0,0,0.28); transform: scale(1.015); }
.role-hint { margin-top: -2px; margin-bottom: 10px; font-size: 10px; line-height: 1.45; color: var(--text-muted); }
.recipient-context-menu { position: fixed; z-index: 100; min-width: 190px; padding: 6px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-card); box-shadow: 0 16px 42px rgba(0,0,0,0.36); }
.recipient-context-title { padding: 7px 8px; font-size: 11px; font-weight: 700; color: var(--text-sec); border-bottom: 1px solid var(--border-subtle); margin-bottom: 4px; }
.context-action { width: 100%; padding: 8px; border-radius: 6px; background: transparent; color: var(--text-sec); display: flex; align-items: center; gap: 8px; text-align: left; font-size: 11px; }
.context-action:hover { background: var(--hover); color: var(--text); }
.context-action[disabled] { opacity: 0.45; cursor: not-allowed; }
.page-drop-target { outline: 1px dashed transparent; outline-offset: 8px; }
.page-drop-target.drag-over { outline-color: var(--accent); }
.field-box { user-select: none; touch-action: none; }
.field-box.selected { z-index: 5; cursor: move; }
.field-content { width: 100%; height: 100%; display: flex; align-items: center; gap: 6px; pointer-events: none; overflow: hidden; }
.field-content span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.resize-handle { position: absolute; z-index: 2; width: 9px; height: 9px; border-radius: 50%; background: var(--bg-card); border: 1.5px solid var(--field-color); box-shadow: 0 0 0 2px var(--bg-card); touch-action: none; }
.resize-handle.n { top: -6px; left: 50%; transform: translateX(-50%); cursor: ns-resize; }
.resize-handle.ne { top: -6px; right: -6px; cursor: nesw-resize; }
.resize-handle.e { right: -6px; top: 50%; transform: translateY(-50%); cursor: ew-resize; }
.resize-handle.se { right: -6px; bottom: -6px; cursor: nwse-resize; }
.resize-handle.s { bottom: -6px; left: 50%; transform: translateX(-50%); cursor: ns-resize; }
.resize-handle.sw { left: -6px; bottom: -6px; cursor: nesw-resize; }
.resize-handle.w { left: -6px; top: 50%; transform: translateY(-50%); cursor: ew-resize; }
.resize-handle.nw { left: -6px; top: -6px; cursor: nwse-resize; }
.field-editor { margin-top: 16px; padding: 12px; }
.field-editor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.field-control { display: flex; flex-direction: column; gap: 4px; font-size: 10px; color: var(--text-muted); }
.field-control.full { grid-column: 1 / -1; }
.field-control input, .field-control select { width: 100%; height: 30px; padding: 0 8px; border: 1px solid var(--border); border-radius: 5px; background: var(--bg-input); color: var(--text); font-size: 12px; outline: none; }
.field-control input:focus, .field-control select:focus { border-color: var(--accent); }
@media (max-width: 920px) { .compose-workspace { flex-direction: column; overflow: auto; } .palette, .right-panel { width: 100%; border: 0; border-bottom: 1px solid var(--border-subtle); } .document-page { width: 560px; } }
`];
public disconnectedCallback = async () => {
this.stopFieldInteraction();
this.stopSigningOrderDrag();
window.removeEventListener('click', this.closeRecipientContextMenu);
await super.disconnectedCallback();
};
private recipientColor(id: number): string {
return this.recipients.find((recipient) => recipient.id === id)?.color || 'var(--accent)';
}
private fieldIcon(type: IFieldPlacement['type']): string {
if (type === 'signature') return 'sign';
if (type === 'date') return 'calendar';
if (type === 'check') return 'check';
return 'type';
}
private fieldDefinition(type: IFieldPlacement['type']): TFieldDefinition {
return fieldDefinitions.find((definition) => definition.type === type) || fieldDefinitions[0];
}
private recipientRoleDefinition(role: TRecipientRole) {
return recipientRoleDefinitions.find((definition) => definition.role === role) || recipientRoleDefinitions[0];
}
private signingRecipients(): IRecipient[] {
return this.recipients.filter((recipient) => recipient.role === 'signer');
}
private clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
private updateField(fieldId: string, patch: Partial<IFieldPlacement>) {
this.fields = this.fields.map((field) => field.id === fieldId ? { ...field, ...patch } : field);
}
private updateSelectedField(patch: Partial<IFieldPlacement>) {
if (!this.selectedFieldId) return;
this.updateField(this.selectedFieldId, patch);
}
private updateSelectedFieldNumber(property: 'x' | 'y' | 'w' | 'h', event: Event) {
const value = Number((event.target as HTMLInputElement).value);
if (!Number.isFinite(value)) return;
const min = property === 'w' || property === 'h' ? 16 : 0;
this.updateSelectedField({ [property]: Math.max(min, Math.round(value)) } as Partial<IFieldPlacement>);
}
private resetSelectedFieldSize(field: IFieldPlacement) {
const definition = this.fieldDefinition(field.type);
this.updateSelectedField({ w: definition.w, h: definition.h });
}
private removeSelectedField() {
if (!this.selectedFieldId) return;
this.fields = this.fields.filter((field) => field.id !== this.selectedFieldId);
this.selectedFieldId = null;
}
private updateRecipientRole(recipientId: number, role: TRecipientRole) {
const recipient = this.recipients.find((currentRecipient) => currentRecipient.id === recipientId);
if (!recipient) return;
const signerCount = this.signingRecipients().length;
if (recipient.role === 'signer' && role !== 'signer' && signerCount <= 1) return;
this.moveRecipientToRole(recipientId, role);
}
private moveRecipientToRole(recipientId: number, role: TRecipientRole, targetIndex?: number) {
const recipient = this.recipients.find((currentRecipient) => currentRecipient.id === recipientId);
if (!recipient) return;
const signerCount = this.signingRecipients().length;
const nextRole = recipient.role === 'signer' && role !== 'signer' && signerCount <= 1 ? 'signer' : role;
const withoutRecipient = this.recipients.filter((currentRecipient) => currentRecipient.id !== recipientId);
const nextByRole = new Map<TRecipientRole, IRecipient[]>();
for (const roleDefinition of recipientRoleDefinitions) {
nextByRole.set(roleDefinition.role, withoutRecipient.filter((currentRecipient) => currentRecipient.role === roleDefinition.role));
}
const targetMembers = [...(nextByRole.get(nextRole) || [])];
const insertIndex = targetIndex === undefined ? targetMembers.length : this.clamp(targetIndex, 0, targetMembers.length);
targetMembers.splice(insertIndex, 0, { ...recipient, role: nextRole });
nextByRole.set(nextRole, targetMembers);
this.recipients = recipientRoleDefinitions.flatMap((roleDefinition) => nextByRole.get(roleDefinition.role) || []).map((currentRecipient, index) => ({ ...currentRecipient, order: index + 1 }));
const nextSigners = this.recipients.filter((currentRecipient) => currentRecipient.role === 'signer');
const fallbackSigner = nextSigners[0];
if (nextRole !== 'signer' && fallbackSigner) {
this.fields = this.fields.map((field) => field.recipient === recipientId ? { ...field, recipient: fallbackSigner.id } : field);
if (this.activeRecipient === recipientId) {
this.activeRecipient = fallbackSigner.id;
}
}
}
private openRecipientContextMenu(event: MouseEvent, recipient: IRecipient) {
event.preventDefault();
event.stopPropagation();
this.recipientContextMenu = { recipientId: recipient.id, x: event.clientX, y: event.clientY };
window.removeEventListener('click', this.closeRecipientContextMenu);
setTimeout(() => window.addEventListener('click', this.closeRecipientContextMenu, { once: true }), 0);
}
private closeRecipientContextMenu = () => {
this.recipientContextMenu = null;
};
private handleDocumentClick = (event: MouseEvent) => {
const target = event.target as HTMLElement | null;
if (target?.closest('.field-box')) return;
this.selectedFieldId = null;
};
private startFieldInteraction(event: PointerEvent, field: IFieldPlacement, mode: TFieldInteraction['mode'], handle?: TResizeHandle) {
if (event.button !== 0) return;
const page = this.shadowRoot?.querySelector('.document-page') as HTMLElement | null;
if (!page) return;
const pageRect = page.getBoundingClientRect();
this.selectedFieldId = field.id;
this.fieldInteraction = {
fieldId: field.id,
mode,
handle,
startClientX: event.clientX,
startClientY: event.clientY,
startField: { ...field },
pageWidth: pageRect.width,
pageHeight: pageRect.height,
};
event.preventDefault();
event.stopPropagation();
window.addEventListener('pointermove', this.handleFieldPointerMove, { passive: false });
window.addEventListener('pointerup', this.stopFieldInteraction);
window.addEventListener('pointercancel', this.stopFieldInteraction);
}
private startFieldMove(event: PointerEvent, field: IFieldPlacement) {
this.startFieldInteraction(event, field, 'move');
}
private startFieldResize(event: PointerEvent, field: IFieldPlacement, handle: TResizeHandle) {
this.startFieldInteraction(event, field, 'resize', handle);
}
private handleFieldPointerMove = (event: PointerEvent) => {
if (!this.fieldInteraction) return;
event.preventDefault();
const interaction = this.fieldInteraction;
const dx = event.clientX - interaction.startClientX;
const dy = event.clientY - interaction.startClientY;
const start = interaction.startField;
if (interaction.mode === 'move') {
this.updateField(interaction.fieldId, {
x: Math.round(this.clamp(start.x + dx, 0, interaction.pageWidth - start.w)),
y: Math.round(this.clamp(start.y + dy, 0, interaction.pageHeight - start.h)),
});
return;
}
const minWidth = 32;
const minHeight = 24;
let x = start.x;
let y = start.y;
let w = start.w;
let h = start.h;
const handle = interaction.handle || 'se';
if (handle.includes('e')) {
w = this.clamp(start.w + dx, minWidth, interaction.pageWidth - start.x);
}
if (handle.includes('s')) {
h = this.clamp(start.h + dy, minHeight, interaction.pageHeight - start.y);
}
if (handle.includes('w')) {
x = this.clamp(start.x + dx, 0, start.x + start.w - minWidth);
w = start.x + start.w - x;
}
if (handle.includes('n')) {
y = this.clamp(start.y + dy, 0, start.y + start.h - minHeight);
h = start.y + start.h - y;
}
this.updateField(interaction.fieldId, {
x: Math.round(x),
y: Math.round(y),
w: Math.round(w),
h: Math.round(h),
});
};
private stopFieldInteraction = () => {
this.fieldInteraction = null;
window.removeEventListener('pointermove', this.handleFieldPointerMove);
window.removeEventListener('pointerup', this.stopFieldInteraction);
window.removeEventListener('pointercancel', this.stopFieldInteraction);
};
private visualSigningOrder(): IRecipient[] {
if (!this.signingOrderDrag) return this.recipients;
const dragged = this.recipients.find((recipient) => recipient.id === this.signingOrderDrag?.recipientId);
if (!dragged) return this.recipients;
const others = this.recipients.filter((recipient) => recipient.role === this.signingOrderDrag?.targetRole && recipient.id !== dragged.id);
const targetIndex = this.clamp(this.signingOrderDrag.targetIndex, 0, others.length);
return [...others.slice(0, targetIndex), dragged, ...others.slice(targetIndex)];
}
private recipientsForRole(role: TRecipientRole): IRecipient[] {
if (!this.signingOrderDrag) return this.recipients.filter((recipient) => recipient.role === role);
const dragged = this.recipients.find((recipient) => recipient.id === this.signingOrderDrag?.recipientId);
const recipients = this.recipients.filter((recipient) => recipient.role === role && recipient.id !== dragged?.id);
if (!dragged || this.signingOrderDrag.targetRole !== role) return recipients;
const targetIndex = this.clamp(this.signingOrderDrag.targetIndex, 0, recipients.length);
return [...recipients.slice(0, targetIndex), { ...dragged, role }, ...recipients.slice(targetIndex)];
}
private startSigningOrderDrag(event: PointerEvent, recipient: IRecipient) {
if (event.button !== 0) return;
const target = event.target as HTMLElement | null;
if (target?.closest('select, input, button')) return;
const item = event.currentTarget as HTMLElement;
const list = item.closest('.signing-order-list') as HTMLElement | null;
if (!list) return;
const section = item.closest('.routing-role-section') as HTMLElement | null;
const role = (section?.dataset.role || recipient.role) as TRecipientRole;
const itemRect = item.getBoundingClientRect();
const listRect = list.getBoundingClientRect();
const marginBottom = Number.parseFloat(globalThis.getComputedStyle(item).marginBottom || '0');
const startIndex = this.recipients.filter((currentRecipient) => currentRecipient.role === role).findIndex((currentRecipient) => currentRecipient.id === recipient.id);
this.signingOrderDrag = {
recipientId: recipient.id,
pointerY: event.clientY,
listTop: listRect.top,
grabOffsetY: event.clientY - itemRect.top,
itemHeight: itemRect.height,
itemStep: itemRect.height + marginBottom,
targetRole: role,
targetIndex: Math.max(0, startIndex),
};
event.preventDefault();
window.addEventListener('pointermove', this.handleSigningOrderPointerMove, { passive: false });
window.addEventListener('pointerup', this.stopSigningOrderDrag);
window.addEventListener('pointercancel', this.stopSigningOrderDrag);
}
private handleSigningOrderPointerMove = (event: PointerEvent) => {
if (!this.signingOrderDrag) return;
event.preventDefault();
const drag = this.signingOrderDrag;
const target = this.shadowRoot?.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null;
const section = target?.closest('.routing-role-section') as HTMLElement | null;
const roleCandidate = (section?.dataset.role || drag.targetRole) as TRecipientRole;
const draggedRecipient = this.recipients.find((recipient) => recipient.id === drag.recipientId);
const targetRole = draggedRecipient?.role === 'signer' && roleCandidate !== 'signer' && this.signingRecipients().length <= 1 ? 'signer' : roleCandidate;
const list = this.shadowRoot?.querySelector(`.routing-role-section[data-role="${targetRole}"] .signing-order-list`) as HTMLElement | null;
const listRect = list?.getBoundingClientRect();
const listTop = listRect?.top ?? drag.listTop;
const targetMemberCount = this.recipients.filter((recipient) => recipient.role === targetRole && recipient.id !== drag.recipientId).length;
const draggedCenterY = event.clientY - listTop - drag.grabOffsetY + drag.itemStep / 2;
const targetIndex = Math.round(this.clamp(draggedCenterY / drag.itemStep, 0, targetMemberCount));
this.signingOrderDrag = { ...drag, pointerY: event.clientY, listTop, targetRole, targetIndex };
};
private stopSigningOrderDrag = () => {
if (this.signingOrderDrag) {
this.moveRecipientToRole(this.signingOrderDrag.recipientId, this.signingOrderDrag.targetRole, this.signingOrderDrag.targetIndex);
}
this.signingOrderDrag = null;
window.removeEventListener('pointermove', this.handleSigningOrderPointerMove);
window.removeEventListener('pointerup', this.stopSigningOrderDrag);
window.removeEventListener('pointercancel', this.stopSigningOrderDrag);
};
private addFieldFromDrop(event: DragEvent) {
event.preventDefault();
const page = event.currentTarget as HTMLElement;
page.classList.remove('drag-over');
const transferredType = event.dataTransfer?.getData('application/x-signature-field') as IFieldPlacement['type'];
if (!this.draggedFieldDefinition && !transferredType) return;
const definition = this.draggedFieldDefinition || this.fieldDefinition(transferredType);
const transferredOffset = event.dataTransfer?.getData('application/x-signature-field-offset');
const offset = this.draggedFieldGrabOffset || (transferredOffset ? JSON.parse(transferredOffset) as { x: number; y: number } : { x: definition.w / 2, y: definition.h / 2 });
const rect = page.getBoundingClientRect();
const x = Math.round(event.clientX - rect.left - offset.x);
const y = Math.round(event.clientY - rect.top - offset.y);
const nextField: IFieldPlacement = {
id: `field_${Date.now()}`,
type: definition.type,
x: Math.max(0, Math.min(Math.max(0, rect.width - definition.w), x)),
y: Math.max(0, Math.min(Math.max(0, rect.height - definition.h), y)),
w: definition.w,
h: definition.h,
page: 1,
recipient: this.activeRecipient,
label: definition.label,
};
this.fields = [...this.fields, nextField];
this.selectedFieldId = nextField.id;
this.draggedFieldDefinition = null;
this.draggedFieldGrabOffset = null;
}
private startFieldToolDrag(event: DragEvent, fieldType: TFieldDefinition) {
const toolRect = (event.currentTarget as HTMLElement).getBoundingClientRect();
const offset = {
x: Math.round(event.clientX - toolRect.left),
y: Math.round(event.clientY - toolRect.top),
};
this.draggedFieldDefinition = fieldType;
this.draggedFieldGrabOffset = offset;
event.dataTransfer?.setData('application/x-signature-field', fieldType.type);
event.dataTransfer?.setData('application/x-signature-field-offset', JSON.stringify(offset));
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'copy';
}
private endFieldToolDrag() {
this.draggedFieldDefinition = null;
this.draggedFieldGrabOffset = null;
}
private renderFieldEditor(field: IFieldPlacement): TemplateResult {
return html`
<div class="card field-editor">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
<div style="font-size: 11px; font-weight: 600;">Field editor</div>
${pill(this.fieldDefinition(field.type).label, 'info', true)}
</div>
<div class="field-editor-grid">
<label class="field-control full">Label<input .value=${field.label} @input=${(event: Event) => this.updateSelectedField({ label: (event.target as HTMLInputElement).value })} /></label>
<label class="field-control full">Assigned signer<select .value=${String(field.recipient)} @change=${(event: Event) => this.updateSelectedField({ recipient: Number((event.target as HTMLSelectElement).value) })}>${this.signingRecipients().map((recipient) => html`<option value=${String(recipient.id)}>${recipient.order}. ${recipient.name}</option>`)}</select></label>
<label class="field-control">X<input type="number" min="0" .value=${String(field.x)} @input=${(event: Event) => this.updateSelectedFieldNumber('x', event)} /></label>
<label class="field-control">Y<input type="number" min="0" .value=${String(field.y)} @input=${(event: Event) => this.updateSelectedFieldNumber('y', event)} /></label>
<label class="field-control">Width<input type="number" min="16" .value=${String(field.w)} @input=${(event: Event) => this.updateSelectedFieldNumber('w', event)} /></label>
<label class="field-control">Height<input type="number" min="16" .value=${String(field.h)} @input=${(event: Event) => this.updateSelectedFieldNumber('h', event)} /></label>
</div>
<div style="display: flex; gap: 8px; margin-top: 12px;">
<button class="btn outline small" style="flex: 1;" @click=${() => this.resetSelectedFieldSize(field)}>Reset size</button>
<button class="btn ghost small" style="color: var(--error);" @click=${() => this.removeSelectedField()}>Delete</button>
</div>
</div>
`;
}
private renderResizeHandles(field: IFieldPlacement): TemplateResult {
return html`${resizeHandles.map((handle) => html`<span class="resize-handle ${handle}" @pointerdown=${(event: PointerEvent) => this.startFieldResize(event, field, handle)}></span>`)}`;
}
private renderSigningRecipient(recipient: IRecipient, orderNumber: number, options: { overlayTop?: number; rowTop?: number; displayRole?: TRecipientRole } = {}): TemplateResult {
const initials = recipient.name.split(' ').map((part) => part[0]).slice(0, 2).join('');
const isOverlay = options.overlayTop !== undefined;
const top = options.overlayTop ?? options.rowTop;
const displayRole = options.displayRole || recipient.role;
return html`<div class="recipient-line signing-recipient ${isOverlay ? 'signing-drag-overlay' : ''}" style="${top !== undefined ? `--routing-top: ${top}px;` : ''}" @contextmenu=${(event: MouseEvent) => this.openRecipientContextMenu(event, recipient)} @pointerdown=${!isOverlay ? (event: PointerEvent) => this.startSigningOrderDrag(event, recipient) : undefined}><span class="mono" style="width: 14px; font-size: 10px; color: ${isOverlay ? 'var(--accent)' : 'var(--text-muted)'};">${orderNumber}</span><span class="avatar" style="background: ${recipient.color};">${initials}</span><div style="flex: 1; min-width: 0;"><div style="font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${recipient.name}</div><div class="mono" style="font-size: 10px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${this.recipientRoleDefinition(displayRole).description}</div></div><span class="role-chip">${this.recipientRoleDefinition(displayRole).shortLabel}</span></div>`;
}
private renderRoleSection(roleDefinition: typeof recipientRoleDefinitions[number]): TemplateResult {
const role = roleDefinition.role;
const members = this.recipientsForRole(role);
const isTargetRole = this.signingOrderDrag?.targetRole === role;
const draggedRecipientId = this.signingOrderDrag?.recipientId;
const draggedRecipient = draggedRecipientId !== undefined ? this.recipients.find((recipient) => recipient.id === draggedRecipientId) : undefined;
if (!this.signingOrderDrag) {
return html`<div class="routing-role-section" data-role=${role}><div class="routing-role-head"><span class="routing-role-title">${roleDefinition.label}</span><span class="role-count">${members.length}</span></div><div class="routing-role-description">${roleDefinition.description}</div><div class="signing-order-list">${members.map((recipient, index) => this.renderSigningRecipient(recipient, index + 1))}</div></div>`;
}
const visualIndexById = new Map(members.map((recipient, index) => [recipient.id, index]));
const overlayTop = isTargetRole ? this.signingOrderDrag.pointerY - this.signingOrderDrag.listTop - this.signingOrderDrag.grabOffsetY : 0;
const draggedOrder = draggedRecipient ? (members.findIndex((recipient) => recipient.id === draggedRecipient.id) + 1 || this.signingOrderDrag.targetIndex + 1) : 0;
return html`<div class="routing-role-section ${isTargetRole ? 'active-drop' : ''}" data-role=${role}><div class="routing-role-head"><span class="routing-role-title">${roleDefinition.label}</span><span class="role-count">${members.filter((recipient) => recipient.id !== draggedRecipientId).length + (isTargetRole ? 1 : 0)}</span></div><div class="routing-role-description">${roleDefinition.description}</div><div class="signing-order-list dragging" style="--routing-list-height: ${Math.max(1, members.length) * this.signingOrderDrag.itemStep}px;">
${members.filter((recipient) => recipient.id !== draggedRecipientId).map((recipient) => {
const visualIndex = visualIndexById.get(recipient.id) ?? 0;
return this.renderSigningRecipient(recipient, visualIndex + 1, { rowTop: visualIndex * this.signingOrderDrag!.itemStep, displayRole: role });
})}
${isTargetRole ? html`<div class="signing-placeholder" style="--routing-top: ${this.signingOrderDrag.targetIndex * this.signingOrderDrag.itemStep}px; --routing-row-height: ${this.signingOrderDrag.itemHeight}px;"></div>` : ''}
${isTargetRole && draggedRecipient ? this.renderSigningRecipient(draggedRecipient, draggedOrder, { overlayTop, displayRole: role }) : ''}
</div></div>`;
}
private renderSigningOrder(): TemplateResult {
return html`${recipientRoleDefinitions.map((roleDefinition) => this.renderRoleSection(roleDefinition))}`;
}
private renderRecipientContextMenu(): TemplateResult {
if (!this.recipientContextMenu) return html``;
const recipient = this.recipients.find((currentRecipient) => currentRecipient.id === this.recipientContextMenu?.recipientId);
if (!recipient) return html``;
const signerCount = this.signingRecipients().length;
return html`<div class="recipient-context-menu" style="left: ${this.recipientContextMenu.x}px; top: ${this.recipientContextMenu.y}px;" @click=${(event: Event) => event.stopPropagation()}>
<div class="recipient-context-title">${recipient.name}</div>
${recipientRoleDefinitions.map((roleDefinition) => html`<button class="context-action" ?disabled=${recipient.role === 'signer' && roleDefinition.role !== 'signer' && signerCount <= 1} @click=${() => { this.updateRecipientRole(recipient.id, roleDefinition.role); this.closeRecipientContextMenu(); }}>${recipient.role === roleDefinition.role ? icon('check', 12) : html`<span style="width: 12px;"></span>`}<span>${roleDefinition.label}</span></button>`)}
</div>`;
}
private renderStepper(): TemplateResult {
const labels = ['Upload', 'Place fields', 'Recipients & routing', 'Review & send'];
return html`
<div class="stepper">
${labels.map((label, index) => {
const stepNumber = index + 1;
return html`<button class="step ${stepNumber === this.step ? 'active' : stepNumber < this.step ? 'done' : ''}" @click=${() => this.step = stepNumber}><span class="step-number">${stepNumber < this.step ? '✓' : stepNumber}</span><span>${label}</span>${index < labels.length - 1 ? html`<span style="width: 24px; height: 1px; background: var(--border); margin-left: 8px;"></span>` : ''}</button>`;
})}
<div style="flex: 1;"></div><span class="mono" style="font-size: 11px; color: var(--text-muted);">doc_8mK3pL · 14 pages · 2.4 MB</span>
</div>
`;
}
public render(): TemplateResult {
const selectedField = this.fields.find((field) => field.id === this.selectedFieldId);
return html`
${topBar({ breadcrumb: ['signature.digital', 'Inbox', 'Compose'], title: 'Master Services Agreement', subtitle: pill('Draft · auto-saved'), actions: html`${actionButton('Save draft', 'ghost')}${actionButton('Preview', 'outline', 'eye')}${actionButton('Send for signature', 'primary', 'send')}` })}
${this.renderStepper()}
<div class="compose-workspace">
${this.renderRecipientContextMenu()}
<div class="palette">
<div class="label-upper">Drag onto document</div>
${fieldDefinitions.map((fieldType) => html`<div class="field-tool" style="--tool-w: ${fieldType.w}px; --tool-h: ${fieldType.h}px; --recipient-color: ${this.recipientColor(this.activeRecipient)};" draggable="true" @dragstart=${(event: DragEvent) => this.startFieldToolDrag(event, fieldType)} @dragend=${() => this.endFieldToolDrag()}>${icon(fieldType.icon, 14)}<span style="flex: 1;">${fieldType.label}</span></div>`)}
<div style="height: 1px; background: var(--border-subtle); margin: 20px 0 16px;"></div>
<div class="label-upper">Active for</div>
${this.signingRecipients().map((recipient) => html`<div class="recipient-line ${this.activeRecipient === recipient.id ? 'active' : ''}" @click=${() => this.activeRecipient = recipient.id}><span class="swatch" style="--recipient-color: ${recipient.color};"></span><span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${recipient.name.split(' ')[0]}</span><span class="mono" style="font-size: 10px; color: var(--text-muted);">${this.fields.filter((field) => field.recipient === recipient.id).length}</span></div>`)}
</div>
<div class="document-stage">
<div class="document-page page-drop-target" @click=${this.handleDocumentClick} @dragover=${(event: DragEvent) => { event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; (event.currentTarget as HTMLElement).classList.add('drag-over'); }} @dragleave=${(event: DragEvent) => (event.currentTarget as HTMLElement).classList.remove('drag-over')} @drop=${(event: DragEvent) => this.addFieldFromDrop(event)}>
${fakeDocument()}
${this.fields.map((field) => html`<div class="field-box ${this.selectedFieldId === field.id ? 'selected' : ''}" style="--x: ${field.x}px; --y: ${field.y}px; --w: ${field.w}px; --h: ${field.h}px; --field-color: ${this.recipientColor(field.recipient)};" @click=${() => this.selectedFieldId = field.id} @pointerdown=${(event: PointerEvent) => this.startFieldMove(event, field)}><div class="field-content">${icon(this.fieldIcon(field.type), 12)}<span>${field.label}</span></div>${this.selectedFieldId === field.id ? this.renderResizeHandles(field) : ''}</div>`)}
<div class="mono" style="position: absolute; bottom: 12px; right: 16px; font-size: 9px; color: hsl(0 0% 60%);">Page 1 of 14</div>
</div>
<div style="display: flex; align-items: center; gap: 10px; font-size: 11px; color: var(--text-muted);">${actionButton('Prev', 'outline')}${html`<span class="mono">1 / 14</span>`}${actionButton('Next', 'outline')}</div>
</div>
<div class="right-panel">
<div class="label-upper">Routing order · drag to reorder</div>
<div class="role-hint">Choose who signs, who gets the completed copy, and who is notified at every step.</div>
${this.renderSigningOrder()}
${selectedField ? this.renderFieldEditor(selectedField) : ''}
</div>
</div>
`;
}
}
@@ -0,0 +1,47 @@
import { DeesElement, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
import { actionButton, icon, pill, topBar, workspaceBaseStyles, workspaceDemoFrame } from './sdig-workspace.shared.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-workspace-developers': SdigWorkspaceDevelopers;
}
}
@customElement('sdig-workspace-developers')
export class SdigWorkspaceDevelopers extends DeesElement {
public static demo = () => workspaceDemoFrame(html`<sdig-workspace-developers></sdig-workspace-developers>`);
public static demoGroups = ['Signature Digital Workspace'];
public static styles = [workspaceBaseStyles, css`
.developer-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 20px; }
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.metric-card { padding: 14px; }
.metric-value { font-family: 'Plus Jakarta Sans', Inter, sans-serif; font-size: 22px; font-weight: 600; letter-spacing: -0.02em; }
pre.code { margin: 0; padding: 16px; background: var(--bg-el); border: 1px solid var(--border-subtle); border-radius: 6px; font-size: 12px; line-height: 1.7; color: var(--text-sec); overflow: auto; }
@media (max-width: 920px) { .developer-grid, .stats-grid { grid-template-columns: 1fr; } }
`];
public render(): TemplateResult {
return html`
${topBar({ breadcrumb: ['signature.digital', 'Developers'], title: 'Developers', subtitle: pill('API · v0.42', 'info', true), actions: html`${actionButton('View on GitHub', 'outline', 'github')}${actionButton('New API key', 'primary', 'plus')}` })}
<div class="content-scroll developer-grid">
<div style="display: flex; flex-direction: column; gap: 16px;">
<div class="card" style="padding: 20px;"><div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px;"><span style="font-size: 14px; font-weight: 600;">Send a document in 8 lines</span><span class="pill">node</span></div><pre class="code mono">import { Signature } from '@signature.digital/sdk';
const sig = new Signature(process.env.SIGD_KEY);
await sig.documents.send({
file: './msa.pdf',
recipients: [{ name: 'Sarah Chen', email: 'sarah@acme.com' }],
fields: 'auto',
});</pre></div>
<div class="stats-grid">${[['Requests this month', '14,892', '+8.2%'], ['P95 latency', '142ms', '-12ms'], ['Error rate', '0.04%', '✓']].map((metric) => html`<div class="card metric-card"><div class="metric-value">${metric[1]}</div><div style="font-size: 10px; color: var(--text-muted); margin-top: 2px;">${metric[0]}</div><div class="mono" style="font-size: 10px; color: var(--success); margin-top: 6px;">${metric[2]}</div></div>`)}</div>
</div>
<div style="display: flex; flex-direction: column; gap: 16px;">
<div class="card" style="padding: 16px;"><div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px;">${icon('github', 14)}<span style="font-size: 12px; font-weight: 600;">signature-digital/core</span></div><div style="font-size: 11px; color: var(--text-muted); line-height: 1.5;">MIT-licensed. Self-host on your own infra, or use signature.digital cloud.</div></div>
<div class="card" style="padding: 16px;"><div class="label-upper">Self-host status</div>${[['Docker image', 'ghcr.io/signature-digital'], ['Helm chart', 'v0.42.1'], ['Postgres ≥ 14', 'required'], ['S3-compatible', 'optional']].map((row) => html`<div style="display: flex; justify-content: space-between; font-size: 11px; margin-bottom: 6px;"><span style="color: var(--text-muted);">${row[0]}</span><span class="mono" style="color: var(--text-sec);">${row[1]}</span></div>`)}</div>
</div>
</div>
`;
}
}
@@ -0,0 +1,117 @@
import { DeesElement, property, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
import { actionButton, demoDocuments, icon, pill, requestWorkspaceView, topBar, workspaceBaseStyles, type IDocumentRow, type TDensity } from './sdig-workspace.shared.js';
import { workspaceDemoFrame } from './sdig-workspace.shared.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-workspace-inbox': SdigWorkspaceInbox;
}
}
@customElement('sdig-workspace-inbox')
export class SdigWorkspaceInbox extends DeesElement {
public static demo = () => workspaceDemoFrame(html`<sdig-workspace-inbox></sdig-workspace-inbox>`);
public static demoGroups = ['Signature Digital Workspace'];
@property({ type: String }) public accessor density: TDensity = 'comfortable';
@state() private accessor filter: string = 'all';
@state() private accessor search: string = '';
public static styles = [workspaceBaseStyles, css`
.filterbar { padding: 14px 24px; border-bottom: 1px solid var(--border-subtle); display: flex; align-items: center; gap: 8px; }
.searchbox { display: flex; align-items: center; gap: 8px; padding: 0 10px; height: 32px; width: 280px; background: var(--bg-input); border: 1px solid var(--border); border-radius: 6px; }
.searchbox input { flex: 1; min-width: 0; background: transparent; border: none; outline: none; color: var(--text); font-size: 12px; }
.segmented { display: flex; gap: 2px; padding: 2px; background: var(--bg-el); border-radius: 6px; border: 1px solid var(--border-subtle); }
.segmented button { padding: 4px 10px; font-size: 11px; font-weight: 500; border-radius: 4px; background: transparent; color: var(--text-muted); display: inline-flex; align-items: center; gap: 5px; }
.segmented button.active { background: var(--bg-card); color: var(--text); box-shadow: inset 0 0 0 1px var(--border); }
.doc-table { min-width: 880px; }
.doc-head, .doc-row { display: grid; grid-template-columns: 32px minmax(220px,2.4fr) 150px 160px 90px 60px 32px; align-items: center; gap: 14px; padding: 0 16px; }
.doc-head { height: 36px; border-bottom: 1px solid var(--border-subtle); color: var(--text-dim); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
.doc-row { height: 60px; border-bottom: 1px solid var(--border-subtle); cursor: pointer; transition: background 0.1s ease; }
.doc-row.compact { height: 48px; }
.doc-row:last-child { border-bottom: 0; }
.doc-row:hover { background: var(--row-hover); }
.doc-icon { width: 28px; height: 32px; border-radius: 4px; background: var(--bg-input); border: 1px solid var(--border); display: flex; align-items: center; justify-content: center; }
.doc-title { font-size: 13px; color: var(--text); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.doc-meta { margin-top: 2px; font-size: 11px; color: var(--text-muted); }
.recipient-stack { display: flex; align-items: center; }
.recipient-dot { width: 22px; height: 22px; border-radius: 50%; background: var(--bg-input); border: 1.5px solid var(--border); margin-left: -6px; font-size: 9px; font-weight: 600; color: var(--text-sec); display: flex; align-items: center; justify-content: center; }
.recipient-dot:first-child { margin-left: 0; }
.recipient-dot.signed { border-color: var(--success); color: var(--success); background: var(--bg-el); }
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-top: 24px; }
.metric-card { padding: 16px; }
.metric-value { font-family: 'Plus Jakarta Sans', Inter, sans-serif; font-size: 22px; font-weight: 600; letter-spacing: -0.02em; }
@media (max-width: 920px) { .filterbar { padding: 12px 16px; display: block; } .searchbox { width: 100%; margin-bottom: 10px; } .stats-grid { grid-template-columns: 1fr; } }
`];
private get filteredDocuments(): IDocumentRow[] {
return demoDocuments
.filter((doc) => this.filter === 'all' || doc.status === this.filter)
.filter((doc) => !this.search || doc.title.toLowerCase().includes(this.search.toLowerCase()));
}
private statusPill(status: IDocumentRow['status']): TemplateResult {
const map = {
awaiting: ['warning', 'awaiting signature'],
signed: ['success', 'completed'],
draft: ['default', 'draft'],
declined: ['error', 'declined'],
} as const;
const [tone, label] = map[status];
return pill(label, tone, true);
}
private openDocument(doc: IDocumentRow) {
requestWorkspaceView(this, doc.status === 'signed' ? 'audit' : 'sign');
}
public render(): TemplateResult {
const filters = [
{ id: 'all', label: 'All', count: demoDocuments.length },
{ id: 'awaiting', label: 'Awaiting', count: demoDocuments.filter((doc) => doc.status === 'awaiting').length },
{ id: 'signed', label: 'Completed', count: demoDocuments.filter((doc) => doc.status === 'signed').length },
{ id: 'draft', label: 'Drafts', count: demoDocuments.filter((doc) => doc.status === 'draft').length },
{ id: 'declined', label: 'Declined', count: demoDocuments.filter((doc) => doc.status === 'declined').length },
];
return html`
${topBar({
breadcrumb: ['signature.digital', 'Lossless GmbH', 'Inbox'],
title: 'Inbox',
subtitle: pill(`${demoDocuments.filter((doc) => doc.status === 'awaiting').length} need attention`, 'info'),
actions: html`${actionButton('Import', 'outline', 'upload')}${actionButton('New document', 'primary', 'plus', () => requestWorkspaceView(this, 'compose'))}`,
})}
<div class="filterbar">
<div class="searchbox">${icon('search', 13)}<input .value=${this.search} @input=${(event: Event) => this.search = (event.target as HTMLInputElement).value} placeholder="Search documents, recipients, IDs..." /><span class="mono" style="font-size: 10px; color: var(--text-dim); border: 1px solid var(--border); border-radius: 3px; padding: 1px 5px;">⌘K</span></div>
<div style="flex: 1;"></div>
<div class="segmented">${filters.map((filter) => html`<button class=${this.filter === filter.id ? 'active' : ''} @click=${() => this.filter = filter.id}>${filter.label}<span class="mono" style="color: var(--text-dim);">${filter.count}</span></button>`)}</div>
</div>
<div class="content-scroll">
<div class="card" style="overflow-x: auto;">
<div class="doc-table">
<div class="doc-head"><span></span><span>Document</span><span>Status</span><span>Recipients</span><span>Deadline</span><span style="text-align: right;">Pages</span><span></span></div>
${this.filteredDocuments.map((doc) => html`
<div class="doc-row ${this.density === 'compact' ? 'compact' : ''}" @click=${() => this.openDocument(doc)}>
<div class="doc-icon">${icon('file', 14)}</div>
<div style="min-width: 0;"><div class="doc-title">${doc.title}</div><div class="doc-meta mono">${doc.id} · ${doc.sender} · ${doc.updated}</div></div>
<div>${this.statusPill(doc.status)}</div>
<div style="display: flex; align-items: center; gap: 8px;"><div class="recipient-stack">${doc.recipients.slice(0, 4).map((recipient) => html`<span class="recipient-dot ${recipient.signed ? 'signed' : ''}" title=${recipient.name}>${recipient.initials}</span>`)}</div><span style="font-size: 11px; color: var(--text-muted);">${doc.recipients.filter((recipient) => recipient.signed).length}/${doc.recipients.length}</span></div>
<div class="mono" style="font-size: 11px; color: ${doc.deadline && doc.status === 'awaiting' ? 'var(--warning)' : 'var(--text-dim)'};">${doc.deadline ? html`${icon('clock', 11)} ${doc.deadline}` : '—'}</div>
<div class="mono" style="font-size: 11px; color: var(--text-muted); text-align: right;">${doc.pages}</div>
<div>${icon('more', 14)}</div>
</div>
`)}
</div>
</div>
<div class="stats-grid">
${[
{ label: 'Sent this month', value: '127', delta: '+24%', icon: 'send' },
{ label: 'Avg time to sign', value: '4.2h', delta: '-18%', icon: 'clock' },
{ label: 'Completion rate', value: '94.1%', delta: '+2.1%', icon: 'check' },
{ label: 'API signatures', value: '2,481', delta: '+312', icon: 'code' },
].map((metric) => html`<div class="card metric-card"><div style="display: flex; justify-content: space-between; margin-bottom: 12px;">${icon(metric.icon, 14)}<span class="mono" style="font-size: 10px; color: var(--success);">${metric.delta}</span></div><div class="metric-value">${metric.value}</div><div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">${metric.label}</div></div>`)}
</div>
</div>
`;
}
}
@@ -0,0 +1,22 @@
import { DeesElement, property, html, customElement, type TemplateResult } from '@design.estate/dees-element';
import { icon, pill, topBar, workspaceBaseStyles, workspaceDemoFrame } from './sdig-workspace.shared.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-workspace-placeholder': SdigWorkspacePlaceholder;
}
}
@customElement('sdig-workspace-placeholder')
export class SdigWorkspacePlaceholder extends DeesElement {
public static demo = () => workspaceDemoFrame(html`<sdig-workspace-placeholder label="Templates" subtitle="Reusable agreement templates"></sdig-workspace-placeholder>`);
public static demoGroups = ['Signature Digital Workspace'];
@property({ type: String }) public accessor label: string = 'Section';
@property({ type: String }) public accessor subtitle: string = 'Coming soon';
public static styles = [workspaceBaseStyles];
public render(): TemplateResult {
return html`${topBar({ breadcrumb: ['signature.digital', this.label], title: this.label, subtitle: pill('coming soon') })}<div class="content-scroll" style="display: flex; align-items: center; justify-content: center; flex-direction: column; color: var(--text-muted); gap: 8px;">${icon('folder', 32)}<div style="font-size: 13px; color: var(--text-sec);">${this.label}</div><div style="font-size: 11px;">${this.subtitle}</div></div>`;
}
}
@@ -0,0 +1,91 @@
import { DeesElement, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
import { actionButton, demoFields, fakeDocument, icon, pill, workspaceBaseStyles, workspaceDemoFrame, type IFieldPlacement } from './sdig-workspace.shared.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-workspace-sign': SdigWorkspaceSign;
}
}
@customElement('sdig-workspace-sign')
export class SdigWorkspaceSign extends DeesElement {
public static demo = () => workspaceDemoFrame(html`<sdig-workspace-sign></sdig-workspace-sign>`);
public static demoGroups = ['Signature Digital Workspace'];
@state() private accessor activeFieldId: string = 'f1';
@state() private accessor signedFieldIds: string[] = [];
public static styles = [workspaceBaseStyles, css`
.recipient-header { height: 56px; flex-shrink: 0; padding: 0 24px; background: var(--bg-card); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
.logomark { width: 28px; height: 28px; border-radius: 6px; background: var(--bg-el); border: 1px solid var(--border-strong); display: inline-flex; align-items: center; justify-content: center; font-family: 'Plus Jakarta Sans', Inter, sans-serif; font-weight: 700; position: relative; }
.logomark::after { content: ''; position: absolute; right: 5px; bottom: 5px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent); }
.sign-layout { flex: 1; display: flex; overflow: hidden; background: hsl(0 0% 96%); color: hsl(0 0% 10%); }
:host-context(sdig-workspace[theme='dark']) .sign-layout { background: hsl(0 0% 6%); color: hsl(0 0% 95%); }
.sign-body { flex: 1; overflow: auto; padding: 32px 32px 80px; display: flex; flex-direction: column; align-items: center; gap: 20px; }
.sign-panel { width: 320px; border-left: 1px solid var(--border); background: var(--bg-card); padding: 20px; overflow: auto; flex-shrink: 0; }
@media (max-width: 920px) { .recipient-header .actions { display: none; } .sign-layout { flex-direction: column; overflow: auto; } .sign-panel { width: 100%; border-left: 0; border-top: 1px solid var(--border); } .document-page { width: 560px; } }
`];
private get signFields() {
return demoFields.slice(0, 3);
}
private fieldIcon(type: IFieldPlacement['type']): string {
if (type === 'signature') return 'sign';
if (type === 'date') return 'calendar';
return 'type';
}
private signField(fieldId: string) {
if (!this.signedFieldIds.includes(fieldId)) {
this.signedFieldIds = [...this.signedFieldIds, fieldId];
}
const next = this.signFields.find((field) => !this.signedFieldIds.includes(field.id) && field.id !== fieldId);
if (next) this.activeFieldId = next.id;
}
private renderSignedValue(field: IFieldPlacement): TemplateResult {
if (field.type === 'signature') return html`<span style="font-family: 'Plus Jakarta Sans', cursive; font-size: 22px; font-weight: 600; font-style: italic; color: hsl(220 50% 30%);">Sarah Chen</span>`;
if (field.type === 'date') return html`<span class="mono" style="font-size: 12px; color: hsl(0 0% 18%);">2026-05-02</span>`;
return html`<span style="font-size: 12px; color: hsl(0 0% 18%);">Sarah Chen</span>`;
}
public render(): TemplateResult {
const completed = this.signedFieldIds.length;
const progress = Math.round((completed / this.signFields.length) * 100);
const activeField = this.signFields.find((field) => field.id === this.activeFieldId) || this.signFields[0];
return html`
<div class="recipient-header">
<div style="display: flex; align-items: center; gap: 12px;"><span class="logomark">s</span><div><div style="font-size: 12px; font-weight: 600;">Master Services Agreement</div><div class="mono" style="font-size: 10px; color: var(--text-muted);">From Lossless GmbH · doc_8mK3pL · 14 pages</div></div></div>
<div class="actions"><span class="pill success">${icon('shield', 12)} Verified sender · DKIM ✓</span>${actionButton('Decline', 'outline')}${actionButton('PDF', 'outline', 'download')}</div>
</div>
<div class="progress-track"><div class="progress-fill" style="width: ${progress}%"></div></div>
<div class="sign-layout">
<div class="sign-body">
<div class="document-page" style="width: 620px; min-height: 820px;">
${fakeDocument()}
${this.signFields.map((field) => {
const filled = this.signedFieldIds.includes(field.id);
const active = this.activeFieldId === field.id && !filled;
return html`<div class="field-box ${active ? 'selected' : ''}" style="--x: ${field.x}px; --y: ${field.y}px; --w: ${field.w}px; --h: ${field.h}px; --field-color: ${filled ? 'transparent' : 'var(--accent)'}; color: ${filled ? 'hsl(220 50% 30%)' : 'var(--accent)'}; background: ${filled ? 'transparent' : 'color-mix(in srgb, var(--accent) 12%, transparent)'};" @click=${() => !filled ? this.signField(field.id) : undefined}>${filled ? this.renderSignedValue(field) : html`${icon(this.fieldIcon(field.type), 12)}<span>${active ? html`<span style="display: inline-block; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); animation: pulse 1.4s infinite;"></span>` : ''}${field.label}</span>`}</div>`;
})}
<div class="mono" style="position: absolute; bottom: 14px; right: 18px; font-size: 9px; color: hsl(0 0% 60%);">Page 1 of 14</div>
</div>
</div>
<div class="sign-panel">
<div style="display: flex; align-items: center; gap: 8px;"><span class="avatar" style="background: #60a5fa;">SC</span><div><div style="font-size: 13px; font-weight: 600;">Hi, Sarah</div><div class="mono" style="font-size: 10px; color: var(--text-muted);">sarah@acme.com</div></div></div>
<div class="card" style="padding: 14px; margin-top: 16px;"><div class="label-upper">Your progress</div><div style="font-size: 24px; font-weight: 700;">${completed} <span style="color: var(--text-muted); font-weight: 400;">/ ${this.signFields.length}</span></div><div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">${this.signFields.length - completed === 0 ? 'All fields complete' : `${this.signFields.length - completed} fields remaining`}</div><div class="progress-track" style="margin-top: 12px;"><div class="progress-fill" style="width: ${progress}%"></div></div></div>
<div style="margin-top: 20px;" class="label-upper">Step by step</div>
${this.signFields.map((field, index) => {
const filled = this.signedFieldIds.includes(field.id);
const active = this.activeFieldId === field.id && !filled;
return html`<div class="recipient-line ${active ? 'active' : ''}" @click=${() => !filled ? this.activeFieldId = field.id : undefined}><span class="avatar" style="width: 22px; height: 22px; background: ${filled ? 'var(--success)' : active ? 'var(--accent)' : 'var(--bg-input)'}; color: ${filled || active ? 'white' : 'var(--text-muted)'};">${filled ? '✓' : index + 1}</span><div style="flex: 1;"><div style="font-size: 12px; font-weight: 500; text-decoration: ${filled ? 'line-through' : 'none'};">${field.label}</div><div class="mono" style="font-size: 10px; color: var(--text-muted);">${field.type} · page ${field.page}</div></div>${active ? icon('chevronRight', 12) : ''}</div>`;
})}
<button class="btn primary" style="width: 100%; height: 44px; margin-top: 20px;" @click=${() => this.signField(activeField.id)}>${completed === this.signFields.length ? 'Finish & submit' : `Continue - ${activeField.label}`}</button>
<div style="margin-top: 12px; padding: 10px; font-size: 10px; color: var(--text-muted); line-height: 1.5; text-align: center; border-radius: 6px; background: var(--bg-el);">By signing, you agree to the ESIGN Act & eIDAS terms.<br /><span class="mono">IP 81.221.4.18 · Brussels, BE</span></div>
</div>
</div>
`;
}
}
@@ -0,0 +1,442 @@
import { html, css, type TemplateResult } from '@design.estate/dees-element';
import '@design.estate/dees-catalog/ts_web/elements/00group-utility/dees-icon/dees-icon.js';
export type TWorkspaceView =
| 'inbox'
| 'compose'
| 'sign'
| 'audit'
| 'developers'
| 'templates'
| 'team'
| 'settings';
export type TWorkspaceTheme = 'dark' | 'light';
export type TDensity = 'compact' | 'comfortable';
export type TRecipientRole = 'signer' | 'copy' | 'updates';
export interface IDocumentRow {
id: string;
title: string;
status: 'awaiting' | 'signed' | 'draft' | 'declined';
recipients: Array<{ name: string; initials: string; signed: boolean }>;
updated: string;
sender: string;
pages: number;
deadline?: string;
}
export interface IRecipient {
id: number;
name: string;
email: string;
color: string;
order: number;
role: TRecipientRole;
}
export interface IFieldPlacement {
id: string;
type: 'signature' | 'date' | 'text' | 'initials' | 'check';
x: number;
y: number;
w: number;
h: number;
page: number;
recipient: number;
label: string;
}
export const demoDocuments: IDocumentRow[] = [
{ id: 'doc_8mK3pL', title: 'Master Services Agreement - Acme Corp', status: 'awaiting', recipients: [{ name: 'Sarah Chen', initials: 'SC', signed: true }, { name: 'David Park', initials: 'DP', signed: false }, { name: 'You', initials: 'PK', signed: true }], updated: '2 min ago', sender: 'You', pages: 14, deadline: 'May 5' },
{ id: 'doc_2nQ7vR', title: 'NDA - Helio Robotics', status: 'signed', recipients: [{ name: 'Marcus Tan', initials: 'MT', signed: true }, { name: 'You', initials: 'PK', signed: true }], updated: '1h ago', sender: 'You', pages: 3 },
{ id: 'doc_5tH1zM', title: 'Series B Term Sheet (Lead) v3', status: 'awaiting', recipients: [{ name: 'Anna Lindqvist', initials: 'AL', signed: false }, { name: 'Roy Banerjee', initials: 'RB', signed: true }, { name: 'You', initials: 'PK', signed: false }], updated: '3h ago', sender: 'Sequoia Counsel', pages: 22, deadline: 'May 3' },
{ id: 'doc_9wB4cX', title: 'Employment Offer - Mira Abebe', status: 'declined', recipients: [{ name: 'Mira Abebe', initials: 'MA', signed: false }, { name: 'You', initials: 'PK', signed: true }], updated: 'yesterday', sender: 'You', pages: 6 },
{ id: 'doc_1jF6kY', title: 'Lease - Berlin office Q3', status: 'draft', recipients: [{ name: 'You', initials: 'PK', signed: false }], updated: 'yesterday', sender: 'You', pages: 11 },
{ id: 'doc_4dN8sP', title: 'API Reseller Agreement - Northwind', status: 'signed', recipients: [{ name: 'Lila Brooks', initials: 'LB', signed: true }, { name: 'You', initials: 'PK', signed: true }], updated: '2 days ago', sender: 'You', pages: 8 },
];
export const demoRecipients: IRecipient[] = [
{ id: 0, name: 'Sarah Chen', email: 'sarah@acme.com', color: '#60a5fa', order: 1, role: 'signer' },
{ id: 1, name: 'David Park', email: 'd.park@acme.com', color: '#fbbf24', order: 2, role: 'signer' },
{ id: 2, name: 'Philipp K.', email: 'philipp@lossless.com', color: '#3b82f6', order: 3, role: 'updates' },
];
export const demoFields: IFieldPlacement[] = [
{ id: 'f1', type: 'signature', x: 60, y: 580, w: 200, h: 50, page: 1, recipient: 0, label: 'Signature' },
{ id: 'f2', type: 'date', x: 320, y: 580, w: 120, h: 30, page: 1, recipient: 0, label: 'Date' },
{ id: 'f3', type: 'text', x: 60, y: 460, w: 280, h: 30, page: 1, recipient: 1, label: 'Full legal name' },
{ id: 'f4', type: 'signature', x: 60, y: 700, w: 200, h: 50, page: 1, recipient: 1, label: 'Counter-signature' },
];
export const workspaceBaseStyles = css`
:host {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
color: var(--text);
background: var(--bg);
font-family: Geist, Inter, Roboto, -apple-system, BlinkMacSystemFont, sans-serif;
font-feature-settings: 'cv11', 'tnum', 'cv05' 1;
}
* { box-sizing: border-box; }
button, input, textarea { font: inherit; }
button { border: 0; cursor: pointer; }
dees-icon { flex-shrink: 0; }
.mono {
font-family: 'Intel One Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
font-variant-numeric: tabular-nums;
}
.topbar {
height: 56px;
flex-shrink: 0;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg);
gap: 12px;
}
.breadcrumb {
font-size: 11px;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.top-title {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.top-title > span:first-child {
font-family: 'Plus Jakarta Sans', Inter, sans-serif;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.btn {
height: 34px;
padding: 0 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
letter-spacing: -0.01em;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
white-space: nowrap;
transition: all 0.12s ease;
}
.btn.small {
height: 28px;
padding: 0 10px;
font-size: 12px;
}
.btn.primary {
background: var(--accent);
color: white;
border: 1px solid var(--accent);
}
.btn.outline {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
.btn.ghost {
background: transparent;
color: var(--text);
border: 1px solid transparent;
}
.btn:hover { background-color: var(--hover); }
.pill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 8px;
border-radius: 999px;
background: var(--bg-el);
color: var(--text-sec);
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
.pill::before {
content: '';
width: 5px;
height: 5px;
display: none;
border-radius: 50%;
background: currentColor;
}
.pill.dot::before { display: block; }
.pill.success { background: rgba(34,197,94,0.12); color: #4ade80; }
.pill.warning { background: rgba(245,158,11,0.12); color: #fbbf24; }
.pill.error { background: rgba(239,68,68,0.12); color: #f87171; }
.pill.info { background: rgba(59,130,246,0.12); color: #60a5fa; }
.content-scroll {
flex: 1;
overflow: auto;
padding: 24px;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.label-upper {
font-size: 10px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 10px;
}
.avatar {
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--accent);
color: white;
font-size: 11px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.document-page {
position: relative;
width: 600px;
min-height: 800px;
background: white;
border-radius: 4px;
box-shadow: 0 8px 32px rgba(0,0,0,0.35), 0 0 0 1px rgba(255,255,255,0.05);
color: hsl(0 0% 20%);
}
.fake-document {
padding: 48px 56px;
font-size: 11px;
line-height: 1.7;
}
.fake-title {
font-family: 'Plus Jakarta Sans', Inter, sans-serif;
font-size: 18px;
font-weight: 700;
margin-bottom: 4px;
color: hsl(0 0% 10%);
}
.fake-line {
height: 6px;
background: hsl(0 0% 82%);
margin-bottom: 7px;
border-radius: 1px;
}
.fake-line.heavy { background: hsl(0 0% 65%); }
.fake-line.short { width: 70%; }
.field-box {
position: absolute;
left: var(--x);
top: var(--y);
width: var(--w);
height: var(--h);
background: color-mix(in srgb, var(--field-color) 13%, transparent);
border: 1.5px dashed var(--field-color);
border-radius: 3px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
font-size: 10px;
font-weight: 500;
color: var(--field-color);
}
.field-box.selected {
border-style: solid;
box-shadow: 0 0 0 4px color-mix(in srgb, var(--field-color) 18%, transparent);
}
.recipient-line {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 12px;
color: var(--text-sec);
margin-bottom: 6px;
}
.recipient-line.active {
background: var(--hover);
border-color: var(--border-strong);
}
.progress-track {
height: 4px;
background: var(--bg-el);
flex-shrink: 0;
}
.progress-fill {
height: 100%;
background: var(--accent);
transition: width 0.4s ease;
}
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
@media (max-width: 920px) {
.topbar { padding: 0 16px; }
.actions { display: none; }
.content-scroll { padding: 16px; }
}
`;
export function icon(name: string, size = 14): TemplateResult {
const iconMap: Record<string, string> = {
inbox: 'lucide:Inbox', plus: 'lucide:Plus', folder: 'lucide:Folder', shield: 'lucide:Shield', code: 'lucide:Code2',
user: 'lucide:User', settings: 'lucide:Settings', upload: 'lucide:Upload', file: 'lucide:FileText', sign: 'lucide:PenTool',
clock: 'lucide:Clock', search: 'lucide:Search', more: 'lucide:MoreHorizontal', send: 'lucide:Send', check: 'lucide:Check',
eye: 'lucide:Eye', calendar: 'lucide:Calendar', type: 'lucide:Type', download: 'lucide:Download', hash: 'lucide:Hash',
github: 'lucide:GitBranch', git: 'lucide:GitBranch', server: 'lucide:Server', star: 'lucide:Star', sparkle: 'lucide:Sparkles',
chevronRight: 'lucide:ChevronRight', chevronDown: 'lucide:ChevronDown', x: 'lucide:X', activity: 'lucide:Activity',
};
return html`<dees-icon .icon=${iconMap[name] || iconMap.file} style="font-size: ${size}px;"></dees-icon>`;
}
export function pill(label: string, tone: 'default' | 'success' | 'warning' | 'error' | 'info' = 'default', dot = false): TemplateResult {
return html`<span class="pill ${tone} ${dot ? 'dot' : ''}">${label}</span>`;
}
export function actionButton(label: string, variant: 'primary' | 'outline' | 'ghost' = 'outline', iconName?: string, onClick?: () => void): TemplateResult {
return html`<button class="btn ${variant}" @click=${onClick || (() => undefined)}>${iconName ? icon(iconName, 13) : ''}${label}</button>`;
}
export function topBar(config: { breadcrumb: string[]; title: string; subtitle?: TemplateResult; actions?: TemplateResult }): TemplateResult {
return html`
<div class="topbar">
<div style="min-width: 0; flex: 1;">
<div class="breadcrumb">
${config.breadcrumb.map((part, index) => html`${index > 0 ? icon('chevronRight', 10) : ''}<span>${part}</span>`)}
</div>
<div class="top-title"><span>${config.title}</span>${config.subtitle || ''}</div>
</div>
<div class="actions">${config.actions || ''}</div>
</div>
`;
}
export function workspaceDemoFrame(content: TemplateResult, theme: TWorkspaceTheme = 'dark'): TemplateResult {
const darkVars = `
--accent: #3b82f6;
--bg: hsl(0 0% 3.9%);
--bg-el: hsl(0 0% 6%);
--bg-card: hsl(0 0% 7%);
--bg-input: hsl(0 0% 9%);
--border: hsl(0 0% 14.9%);
--border-subtle: hsl(0 0% 11%);
--border-strong: hsl(0 0% 20%);
--text: hsl(0 0% 98%);
--text-sec: hsl(0 0% 63.9%);
--text-muted: hsl(0 0% 48%);
--text-dim: hsl(0 0% 32%);
--hover: rgba(255,255,255,0.06);
--hover-subtle: rgba(255,255,255,0.03);
--row-hover: rgba(255,255,255,0.025);
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
`;
const lightVars = `
--accent: #3b82f6;
--bg: hsl(0 0% 99%);
--bg-el: hsl(0 0% 97%);
--bg-card: hsl(0 0% 100%);
--bg-input: hsl(0 0% 98%);
--border: hsl(0 0% 90%);
--border-subtle: hsl(0 0% 93%);
--border-strong: hsl(0 0% 80%);
--text: hsl(0 0% 9%);
--text-sec: hsl(0 0% 32%);
--text-muted: hsl(0 0% 45%);
--text-dim: hsl(0 0% 62%);
--hover: rgba(0,0,0,0.04);
--hover-subtle: rgba(0,0,0,0.02);
--row-hover: rgba(0,0,0,0.02);
--success: #16a34a;
--warning: #d97706;
--error: #dc2626;
`;
return html`<div style="${theme === 'dark' ? darkVars : lightVars} height: 720px; min-height: 720px; background: var(--bg); color: var(--text); overflow: hidden; font-family: Geist, Inter, Roboto, -apple-system, BlinkMacSystemFont, sans-serif;">${content}</div>`;
}
export function fakeDocument(): TemplateResult {
return html`
<div class="fake-document">
<div class="fake-title">Master Services Agreement</div>
<div class="mono" style="font-size: 10px; color: hsl(0 0% 45%); margin-bottom: 24px;">Effective: May 2, 2026 · Acme Corp ↔ Lossless GmbH</div>
${Array.from({ length: 18 }).map((_, index) => html`<div class="fake-line ${index % 5 === 0 ? 'heavy' : ''} ${index % 4 === 3 ? 'short' : ''}"></div>`)}
<div style="height: 16px;"></div>
${Array.from({ length: 8 }).map((_, index) => html`<div class="fake-line ${index % 3 === 2 ? 'short' : ''}"></div>`)}
<div style="margin-top: 60px; font-size: 10px; font-weight: 600; color: hsl(0 0% 35%);">SIGNED ON BEHALF OF ACME CORP</div>
<div style="margin-top: 70px; font-size: 10px; font-weight: 600; color: hsl(0 0% 35%);">SIGNED ON BEHALF OF LOSSLESS GMBH</div>
</div>
`;
}
export function requestWorkspaceView(element: HTMLElement, view: TWorkspaceView) {
element.dispatchEvent(new CustomEvent('workspace-view-request', {
detail: { view },
bubbles: true,
composed: true,
}));
}
@@ -0,0 +1,175 @@
import { DeesElement, property, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
import { icon, type TDensity, type TWorkspaceTheme, type TWorkspaceView } from './sdig-workspace.shared.js';
import './sdig-workspace-inbox.js';
import './sdig-workspace-compose.js';
import './sdig-workspace-sign.js';
import './sdig-workspace-audit.js';
import './sdig-workspace-developers.js';
import './sdig-workspace-placeholder.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-workspace': SdigWorkspace;
}
}
@customElement('sdig-workspace')
export class SdigWorkspace extends DeesElement {
public static demo = () => html`<sdig-workspace></sdig-workspace>`;
public static demoGroups = ['Signature Digital Workspace'];
@property({ type: String }) public accessor accent: string = '#3b82f6';
@property({ type: String }) public accessor density: TDensity = 'comfortable';
@property({ type: String, reflect: true }) public accessor theme: TWorkspaceTheme = 'dark';
@property({ type: String }) public accessor initialView: TWorkspaceView = 'inbox';
@state() private accessor view: TWorkspaceView = 'inbox';
public connectedCallback = async () => {
await super.connectedCallback();
this.view = this.initialView || 'inbox';
this.addEventListener('workspace-view-request', this.handleViewRequest as EventListener);
};
public disconnectedCallback = async () => {
this.removeEventListener('workspace-view-request', this.handleViewRequest as EventListener);
await super.disconnectedCallback();
};
public static styles = css`
:host {
display: block;
width: 100%;
height: 100%;
min-height: 720px;
--accent: #3b82f6;
--bg: hsl(0 0% 3.9%);
--bg-el: hsl(0 0% 6%);
--bg-card: hsl(0 0% 7%);
--bg-input: hsl(0 0% 9%);
--border: hsl(0 0% 14.9%);
--border-subtle: hsl(0 0% 11%);
--border-strong: hsl(0 0% 20%);
--text: hsl(0 0% 98%);
--text-sec: hsl(0 0% 63.9%);
--text-muted: hsl(0 0% 48%);
--text-dim: hsl(0 0% 32%);
--hover: rgba(255,255,255,0.06);
--hover-subtle: rgba(255,255,255,0.03);
--row-hover: rgba(255,255,255,0.025);
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
font-family: Geist, Inter, Roboto, -apple-system, BlinkMacSystemFont, sans-serif;
}
:host([theme='light']) {
--bg: hsl(0 0% 99%);
--bg-el: hsl(0 0% 97%);
--bg-card: hsl(0 0% 100%);
--bg-input: hsl(0 0% 98%);
--border: hsl(0 0% 90%);
--border-subtle: hsl(0 0% 93%);
--border-strong: hsl(0 0% 80%);
--text: hsl(0 0% 9%);
--text-sec: hsl(0 0% 32%);
--text-muted: hsl(0 0% 45%);
--text-dim: hsl(0 0% 62%);
--hover: rgba(0,0,0,0.04);
--hover-subtle: rgba(0,0,0,0.02);
--row-hover: rgba(0,0,0,0.02);
--success: #16a34a;
--warning: #d97706;
--error: #dc2626;
}
* { box-sizing: border-box; }
button { font: inherit; border: 0; cursor: pointer; }
.workspace { display: flex; height: 100%; min-height: 720px; background: var(--bg); color: var(--text); overflow: hidden; }
.sidebar { width: 220px; background: var(--bg); border-right: 1px solid var(--border-subtle); display: flex; flex-direction: column; flex-shrink: 0; height: 100%; }
.brand { padding: 14px 16px 12px; display: flex; align-items: center; gap: 8px; }
.logomark { width: 26px; height: 26px; border-radius: 6px; background: var(--bg-el); border: 1px solid var(--border-strong); display: inline-flex; align-items: center; justify-content: center; font-family: 'Plus Jakarta Sans', Inter, sans-serif; font-size: 13px; font-weight: 700; position: relative; }
.logomark::after, .wordmark::after { content: ''; display: inline-block; border-radius: 50%; background: var(--accent); }
.logomark::after { width: 4px; height: 4px; position: absolute; right: 5px; bottom: 5px; }
.wordmark { font-size: 13px; font-weight: 500; letter-spacing: -0.02em; white-space: nowrap; }
.wordmark .dot { color: var(--text-muted); }
.wordmark::after { width: 4px; height: 4px; margin-left: 3px; transform: translateY(-1px); }
.workspace-card { margin: 0 12px 8px; padding: 7px 10px; background: var(--bg-el); border: 1px solid var(--border-subtle); border-radius: 6px; display: flex; align-items: center; gap: 8px; }
.workspace-badge { width: 18px; height: 18px; border-radius: 4px; background: linear-gradient(135deg, var(--accent), hsl(280 70% 60%)); color: white; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.workspace-name { font-size: 12px; font-weight: 500; line-height: 1.2; }
.workspace-plan { font-size: 10px; color: var(--text-muted); }
.nav-block { padding: 4px 0; }
.nav-item { display: flex; align-items: center; gap: 10px; padding: 7px 10px; margin: 1px 8px; border-radius: 6px; color: var(--text-muted); background: transparent; transition: all 0.1s ease; font-size: 13px; position: relative; width: calc(100% - 16px); text-align: left; }
.compact .nav-item { padding: 5px 10px; }
.nav-item:hover { background: var(--hover-subtle); color: var(--text-sec); }
.nav-item.active { background: var(--hover); color: var(--text); font-weight: 500; }
.nav-item.active::before { content: ''; position: absolute; left: -8px; width: 2px; height: 14px; border-radius: 2px; background: var(--accent); }
.nav-count { margin-left: auto; min-width: 18px; padding: 1px 6px; border-radius: 999px; background: var(--bg-el); color: var(--text-muted); font-size: 10px; text-align: center; }
.github-card { margin: 8px 12px; padding: 10px; border: 1px solid var(--border-subtle); border-radius: 6px; background: var(--bg-el); }
.sparkline { margin-top: 8px; display: flex; gap: 2px; align-items: flex-end; height: 18px; }
.sparkline span { flex: 1; background: var(--border-strong); border-radius: 1px; }
.sparkline span:nth-last-child(-n+4) { background: var(--accent); }
.user-card { padding: 8px 12px; border-top: 1px solid var(--border-subtle); display: flex; align-items: center; gap: 10px; }
.avatar { width: 26px; height: 26px; border-radius: 50%; background: var(--accent); color: white; font-size: 11px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
.view-host { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.statusbar { height: 24px; flex-shrink: 0; border-top: 1px solid var(--border-subtle); background: var(--bg); display: flex; align-items: center; padding: 0 16px; gap: 16px; font-size: 10px; color: var(--text-dim); font-family: 'Intel One Mono', ui-monospace, SFMono-Regular, Menlo, monospace; }
@media (max-width: 920px) { .workspace { flex-direction: column; min-height: 100vh; } .sidebar { width: 100%; height: auto; border-right: 0; border-bottom: 1px solid var(--border-subtle); } .brand, .workspace-card, .github-card, .user-card { display: none; } .nav-block { display: flex; overflow-x: auto; padding: 8px; } .nav-item { width: auto; margin: 0 2px; } .statusbar { display: none; } }
`;
private handleViewRequest = (event: CustomEvent<{ view: TWorkspaceView }>) => {
this.setView(event.detail.view);
};
private setView(viewArg: TWorkspaceView) {
this.view = viewArg;
this.dispatchEvent(new CustomEvent('view-change', { detail: { view: viewArg }, bubbles: true, composed: true }));
}
private navButton(item: { id: TWorkspaceView; label: string; icon: string; count?: number }): TemplateResult {
return html`<button class="nav-item ${this.view === item.id ? 'active' : ''}" @click=${() => this.setView(item.id)}>${icon(item.icon, 15)}<span>${item.label}</span>${item.count !== undefined ? html`<span class="nav-count">${item.count}</span>` : ''}</button>`;
}
private renderSidebar(): TemplateResult {
const navItems = [
{ id: 'inbox', label: 'Inbox', icon: 'inbox', count: 4 },
{ id: 'compose', label: 'Compose', icon: 'plus' },
{ id: 'templates', label: 'Templates', icon: 'folder', count: 12 },
{ id: 'audit', label: 'Audit Trail', icon: 'shield' },
{ id: 'developers', label: 'Developers', icon: 'code' },
] as Array<{ id: TWorkspaceView; label: string; icon: string; count?: number }>;
const lowerItems = [
{ id: 'team', label: 'Team', icon: 'user' },
{ id: 'settings', label: 'Settings', icon: 'settings' },
] as Array<{ id: TWorkspaceView; label: string; icon: string }>;
return html`
<aside class="sidebar">
<div class="brand"><span class="logomark">s</span><span class="wordmark">signature<span class="dot">.</span>digital</span></div>
<div class="workspace-card"><span class="workspace-badge">L</span><div style="flex: 1; min-width: 0;"><div class="workspace-name">Lossless GmbH</div><div class="workspace-plan">Cloud · Pro</div></div>${icon('chevronDown', 12)}</div>
<div class="nav-block">${navItems.map((item) => this.navButton(item))}</div>
<div style="flex: 1;"></div>
<div class="github-card"><div style="display: flex; align-items: center; gap: 6px; margin-bottom: 8px; font-size: 11px; color: var(--text-sec); font-family: 'Intel One Mono', ui-monospace;">${icon('github', 13)} signature-digital/core</div><div style="display: flex; gap: 12px; font-size: 11px; color: var(--text-muted);"><span>${icon('star', 11)} 8.2k</span><span>${icon('git', 11)} 248</span></div><div class="sparkline">${[3, 5, 2, 7, 4, 6, 8, 5, 9, 6, 4, 8, 7, 10].map((height) => html`<span style="height: ${height * 10}%"></span>`)}</div></div>
<div class="nav-block" style="border-top: 1px solid var(--border-subtle); padding-top: 8px;">${lowerItems.map((item) => this.navButton(item))}</div>
<div class="user-card"><span class="avatar">PK</span><div style="flex: 1; min-width: 0;"><div style="font-size: 12px; font-weight: 500;">Philipp K.</div><div style="font-family: 'Intel One Mono', ui-monospace; font-size: 10px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis;">philipp@lossless.com</div></div>${icon('more', 14)}</div>
</aside>
`;
}
private renderView(): TemplateResult {
switch (this.view) {
case 'inbox': return html`<sdig-workspace-inbox class="view-host" .density=${this.density}></sdig-workspace-inbox>`;
case 'compose': return html`<sdig-workspace-compose class="view-host"></sdig-workspace-compose>`;
case 'sign': return html`<sdig-workspace-sign class="view-host"></sdig-workspace-sign>`;
case 'audit': return html`<sdig-workspace-audit class="view-host"></sdig-workspace-audit>`;
case 'developers': return html`<sdig-workspace-developers class="view-host"></sdig-workspace-developers>`;
case 'templates': return html`<sdig-workspace-placeholder class="view-host" label="Templates" subtitle="Reusable agreement templates"></sdig-workspace-placeholder>`;
case 'team': return html`<sdig-workspace-placeholder class="view-host" label="Team" subtitle="Workspace members & roles"></sdig-workspace-placeholder>`;
case 'settings': return html`<sdig-workspace-placeholder class="view-host" label="Settings" subtitle="Workspace, billing, security"></sdig-workspace-placeholder>`;
default: return html`<sdig-workspace-inbox class="view-host" .density=${this.density}></sdig-workspace-inbox>`;
}
}
public render(): TemplateResult {
return html`<div class="workspace ${this.density === 'compact' ? 'compact' : ''}" style="--accent: ${this.accent};" data-screen-label=${this.view}>${this.renderSidebar()}<main class="main">${this.renderView()}<div class="statusbar"><span style="display: inline-flex; align-items: center; gap: 5px;"><span style="width: 6px; height: 6px; border-radius: 50%; background: var(--success);"></span>api.signature.digital</span><span>eu-central-1</span><span>4 sigs queued</span><div style="flex: 1;"></div><span style="color: var(--accent);">Open Source · MIT</span><span>v0.42.1</span><span>${icon('git', 11)} main</span></div></main></div>`;
}
}
+1 -15
View File
@@ -1,17 +1,3 @@
// @signature.digital scope
import * as portablecontract from '@signature.digital/portablecontract';
export {
portablecontract,
}
// @design.estate scope
import * as deesCatalog from '@design.estate/dees-catalog';
export {
deesCatalog,
}
// third party // third party
import signaturePadMod from 'signature_pad'; import signaturePadMod from 'signature_pad';
type signaturePadType = (typeof import('signature_pad'))['default']; type signaturePadType = (typeof import('signature_pad'))['default'];
@@ -19,4 +5,4 @@ const signaturePad = signaturePadMod as any as signaturePadType;
export { export {
signaturePad, signaturePad,
} }
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"compilerOptions": { "compilerOptions": {
"experimentalDecorators": true,
"useDefineForClassFields": false,
"target": "ES2022", "target": "ES2022",
"module": "NodeNext", "module": "NodeNext",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"esModuleInterop": true, "esModuleInterop": true,
"verbatimModuleSyntax": true "verbatimModuleSyntax": true,
"types": ["node"]
}, },
"exclude": [ "exclude": [
"dist_*/**/*.d.ts" "dist_*/**/*.d.ts"