@ashley-shrok/viewmodel-shell 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/package.json +43 -0
- package/src/browser.ts +542 -0
- package/src/index.ts +270 -0
- package/styles/default.css +403 -0
- package/styles/themes/dark-amber.css +6 -0
- package/styles/themes/dark-blue.css +6 -0
- package/styles/themes/dark-green.css +6 -0
- package/styles/themes/dark-rose.css +6 -0
- package/styles/themes/dark-teal.css +6 -0
- package/styles/themes/light-amber.css +21 -0
- package/styles/themes/light-blue.css +21 -0
- package/styles/themes/light-green.css +21 -0
- package/styles/themes/light-purple.css +24 -0
- package/styles/themes/light-rose.css +21 -0
- package/styles/themes/light-teal.css +21 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// ─── Action ──────────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface ActionEvent {
|
|
4
|
+
name: string;
|
|
5
|
+
context?: Record<string, unknown>;
|
|
6
|
+
files?: Record<string, File>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// ─── Adapter interface ────────────────────────────────────────────────────────
|
|
10
|
+
// Implement this to target a new platform (browser, mobile, terminal, …).
|
|
11
|
+
// The core never references HTMLElement, document, or any platform type.
|
|
12
|
+
|
|
13
|
+
export interface Adapter {
|
|
14
|
+
render(vm: ViewNode, onAction: (action: ActionEvent) => void): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ─── Node types ───────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export type ViewNode =
|
|
20
|
+
| PageNode
|
|
21
|
+
| SectionNode
|
|
22
|
+
| ListNode
|
|
23
|
+
| ListItemNode
|
|
24
|
+
| FormNode
|
|
25
|
+
| FieldNode
|
|
26
|
+
| CheckboxNode
|
|
27
|
+
| ButtonNode
|
|
28
|
+
| TextNode
|
|
29
|
+
| LinkNode
|
|
30
|
+
| StatBarNode
|
|
31
|
+
| TabsNode
|
|
32
|
+
| ProgressNode
|
|
33
|
+
| ModalNode
|
|
34
|
+
| TableNode;
|
|
35
|
+
|
|
36
|
+
export interface PageNode {
|
|
37
|
+
type: "page";
|
|
38
|
+
title?: string;
|
|
39
|
+
children: ViewNode[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SectionNode {
|
|
43
|
+
type: "section";
|
|
44
|
+
heading?: string;
|
|
45
|
+
children: ViewNode[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ListNode {
|
|
49
|
+
type: "list";
|
|
50
|
+
id?: string;
|
|
51
|
+
children: ViewNode[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ListItemNode {
|
|
55
|
+
type: "list-item";
|
|
56
|
+
id?: string;
|
|
57
|
+
/** Appended as a BEM modifier: vms-list-item--{variant} */
|
|
58
|
+
variant?: string;
|
|
59
|
+
children: ViewNode[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface FormNode {
|
|
63
|
+
type: "form";
|
|
64
|
+
submitAction: ActionEvent;
|
|
65
|
+
submitLabel?: string;
|
|
66
|
+
children: ViewNode[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface FieldNode {
|
|
70
|
+
type: "field";
|
|
71
|
+
name: string;
|
|
72
|
+
inputType:
|
|
73
|
+
| "text" | "email" | "password" | "number"
|
|
74
|
+
| "date" | "time" | "datetime-local"
|
|
75
|
+
| "textarea" | "hidden" | "file"
|
|
76
|
+
| "select" | "select-multiple" | "checkbox";
|
|
77
|
+
label?: string;
|
|
78
|
+
placeholder?: string;
|
|
79
|
+
value?: string;
|
|
80
|
+
required?: boolean;
|
|
81
|
+
options?: Array<{ value: string; label: string }>;
|
|
82
|
+
/** Dispatched when Enter is pressed (text-like inputs only). Adapter merges { [name]: value } into context. */
|
|
83
|
+
action?: ActionEvent;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface CheckboxNode {
|
|
87
|
+
type: "checkbox";
|
|
88
|
+
name: string;
|
|
89
|
+
checked: boolean;
|
|
90
|
+
label?: string;
|
|
91
|
+
/** Dispatched immediately on change. Adapter merges { checked: boolean } into context. */
|
|
92
|
+
action?: ActionEvent;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ButtonNode {
|
|
96
|
+
type: "button";
|
|
97
|
+
label: string;
|
|
98
|
+
action: ActionEvent;
|
|
99
|
+
variant?: "primary" | "secondary" | "danger";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface TextNode {
|
|
103
|
+
type: "text";
|
|
104
|
+
value: string;
|
|
105
|
+
style?: "heading" | "subheading" | "body" | "muted" | "strikethrough" | "error" | "pre";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface LinkNode {
|
|
109
|
+
type: "link";
|
|
110
|
+
label: string;
|
|
111
|
+
href: string;
|
|
112
|
+
/** true = open outside current app context (browser: new tab + noopener) */
|
|
113
|
+
external?: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface StatBarNode {
|
|
117
|
+
type: "stat-bar";
|
|
118
|
+
stats: Array<{ label: string; value: string | number }>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface TabsNode {
|
|
122
|
+
type: "tabs";
|
|
123
|
+
selected: string;
|
|
124
|
+
/** Base action. Adapter merges { value: tab.value } into context on click. */
|
|
125
|
+
action: ActionEvent;
|
|
126
|
+
tabs: Array<{ value: string; label: string }>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface ProgressNode {
|
|
130
|
+
type: "progress";
|
|
131
|
+
value: number; // 0–100
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface ModalNode {
|
|
135
|
+
type: "modal";
|
|
136
|
+
title?: string;
|
|
137
|
+
children: ViewNode[];
|
|
138
|
+
/** Optional footer row (typically holds action buttons). Rendered as an inline row at the bottom of the modal. */
|
|
139
|
+
footer?: ViewNode[];
|
|
140
|
+
/** Dispatched when the close button is clicked. If omitted, no close button is rendered. */
|
|
141
|
+
dismissAction?: ActionEvent;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface TableColumn {
|
|
145
|
+
key: string;
|
|
146
|
+
label: string;
|
|
147
|
+
sortable?: boolean;
|
|
148
|
+
filterable?: boolean;
|
|
149
|
+
filterValue?: string;
|
|
150
|
+
/** If set, cell values render as <a href={value}>{linkLabel}</a> */
|
|
151
|
+
linkLabel?: string;
|
|
152
|
+
/** true = open outside current app context (browser: new tab + noopener) */
|
|
153
|
+
linkExternal?: boolean;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface TableRow {
|
|
157
|
+
id?: string;
|
|
158
|
+
cells: Record<string, string>;
|
|
159
|
+
action?: ActionEvent;
|
|
160
|
+
variant?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface TableNode {
|
|
164
|
+
type: "table";
|
|
165
|
+
columns: TableColumn[];
|
|
166
|
+
rows: TableRow[];
|
|
167
|
+
sortColumn?: string;
|
|
168
|
+
sortDirection?: "asc" | "desc";
|
|
169
|
+
/** Base action. Adapter merges { column, direction } into context on header click. */
|
|
170
|
+
sortAction?: ActionEvent;
|
|
171
|
+
/** Base action. Adapter merges { column, value, filters } into context on Enter. */
|
|
172
|
+
filterAction?: ActionEvent;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─── Shell ────────────────────────────────────────────────────────────────────
|
|
176
|
+
// Owns the fetch → render → action → fetch cycle.
|
|
177
|
+
// fetch is universal (browsers, Node 18+, Deno) so it belongs in the core.
|
|
178
|
+
|
|
179
|
+
export interface ShellOptions {
|
|
180
|
+
endpoint: string;
|
|
181
|
+
actionEndpoint: string;
|
|
182
|
+
adapter: Adapter;
|
|
183
|
+
onError?: (err: Error) => void;
|
|
184
|
+
onLoading?: (loading: boolean) => void;
|
|
185
|
+
/** Called before each dispatch — merge the returned headers into every POST request. */
|
|
186
|
+
getRequestHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface ShellResponse {
|
|
190
|
+
vm: ViewNode;
|
|
191
|
+
state: unknown;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export class ViewModelShell {
|
|
195
|
+
private currentVm: ViewNode | null = null;
|
|
196
|
+
private currentState: unknown = null;
|
|
197
|
+
private dispatching = false;
|
|
198
|
+
|
|
199
|
+
constructor(private options: ShellOptions) {}
|
|
200
|
+
|
|
201
|
+
async load(params?: Record<string, string>): Promise<void> {
|
|
202
|
+
const { endpoint, adapter, onError, onLoading } = this.options;
|
|
203
|
+
try {
|
|
204
|
+
onLoading?.(true);
|
|
205
|
+
const url = params ? `${endpoint}?${new URLSearchParams(params)}` : endpoint;
|
|
206
|
+
const extraHeaders = this.options.getRequestHeaders ? await this.options.getRequestHeaders() : {};
|
|
207
|
+
const res = await fetch(url, { headers: { Accept: "application/json", ...extraHeaders } });
|
|
208
|
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
209
|
+
const body = (await res.json()) as ShellResponse;
|
|
210
|
+
this.currentVm = body.vm;
|
|
211
|
+
this.currentState = body.state;
|
|
212
|
+
adapter.render(body.vm, (action) => this.dispatch(action));
|
|
213
|
+
} catch (err) {
|
|
214
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
215
|
+
onError ? onError(error) : console.error("[ViewModelShell]", error);
|
|
216
|
+
} finally {
|
|
217
|
+
onLoading?.(false);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async dispatch(action: ActionEvent): Promise<void> {
|
|
222
|
+
if (this.dispatching) return;
|
|
223
|
+
const { actionEndpoint, adapter, onError, onLoading } = this.options;
|
|
224
|
+
if (this.currentState === null) {
|
|
225
|
+
const err = new Error(
|
|
226
|
+
`Cannot dispatch '${action.name}' before initial load completes. ` +
|
|
227
|
+
`Call shell.load() and wait for it before allowing user interaction.`
|
|
228
|
+
);
|
|
229
|
+
onError ? onError(err) : console.error("[ViewModelShell]", err);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
this.dispatching = true;
|
|
234
|
+
onLoading?.(true);
|
|
235
|
+
const form = new FormData();
|
|
236
|
+
form.append("_action", JSON.stringify({ name: action.name, context: action.context ?? {} }));
|
|
237
|
+
form.append("_state", JSON.stringify(this.currentState));
|
|
238
|
+
if (action.files) {
|
|
239
|
+
for (const [name, file] of Object.entries(action.files)) {
|
|
240
|
+
form.append(name, file);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const extraHeaders = this.options.getRequestHeaders ? await this.options.getRequestHeaders() : {};
|
|
244
|
+
const res = await fetch(actionEndpoint, {
|
|
245
|
+
method: "POST",
|
|
246
|
+
headers: { Accept: "application/json", ...extraHeaders },
|
|
247
|
+
body: form,
|
|
248
|
+
});
|
|
249
|
+
if (!res.ok) throw new Error(`Action '${action.name}' failed: ${res.status}`);
|
|
250
|
+
const body = (await res.json()) as ShellResponse;
|
|
251
|
+
this.currentVm = body.vm;
|
|
252
|
+
this.currentState = body.state;
|
|
253
|
+
adapter.render(body.vm, (a) => this.dispatch(a));
|
|
254
|
+
} catch (err) {
|
|
255
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
256
|
+
onError ? onError(error) : console.error("[ViewModelShell]", error);
|
|
257
|
+
} finally {
|
|
258
|
+
this.dispatching = false;
|
|
259
|
+
onLoading?.(false);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
getCurrentVm(): ViewNode | null {
|
|
264
|
+
return this.currentVm;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
getCurrentState(): unknown {
|
|
268
|
+
return this.currentState;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
2
|
+
ViewModel Shell — default stylesheet
|
|
3
|
+
Optional. Import via `import "viewmodel-shell/styles.css"` for a working
|
|
4
|
+
baseline. All colours, fonts, and spacing surface as CSS variables so
|
|
5
|
+
themes can be overridden without touching rules:
|
|
6
|
+
|
|
7
|
+
:root {
|
|
8
|
+
--vms-accent: #4a9eff;
|
|
9
|
+
--vms-bg: #fff;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
The framework emits `vms-*` classes (see AGENTS.md). This file styles
|
|
13
|
+
every emitted class plus a recommended `.vms-error` banner pattern for
|
|
14
|
+
the `onError` callback.
|
|
15
|
+
───────────────────────────────────────────────────────────────────────── */
|
|
16
|
+
|
|
17
|
+
:root {
|
|
18
|
+
--vms-bg: #0f0f11;
|
|
19
|
+
--vms-surface: #18181c;
|
|
20
|
+
--vms-surface-2: #222228;
|
|
21
|
+
--vms-border: #2e2e38;
|
|
22
|
+
--vms-accent: #7c6af7;
|
|
23
|
+
--vms-accent-glow: rgba(124, 106, 247, 0.18);
|
|
24
|
+
--vms-accent-dim: rgba(124, 106, 247, 0.35);
|
|
25
|
+
--vms-text: #e8e8f0;
|
|
26
|
+
--vms-text-muted: #6b6b80;
|
|
27
|
+
--vms-done-bg: #3a3a48;
|
|
28
|
+
--vms-done-text: #5a5a6e;
|
|
29
|
+
--vms-error: #e05a5a;
|
|
30
|
+
--vms-error-glow: rgba(224, 90, 90, 0.12);
|
|
31
|
+
--vms-warning: #e0a823;
|
|
32
|
+
--vms-priority-high: #e07015;
|
|
33
|
+
--vms-success: #4dd17a;
|
|
34
|
+
--vms-info: #4a9eff;
|
|
35
|
+
--vms-radius: 10px;
|
|
36
|
+
--vms-radius-sm: 6px;
|
|
37
|
+
--vms-font-body: ui-monospace, 'DM Mono', 'Fira Mono', Menlo, monospace;
|
|
38
|
+
--vms-font-head: 'DM Serif Display', Georgia, serif;
|
|
39
|
+
--vms-t: 0.15s ease;
|
|
40
|
+
--vms-color-scheme: dark;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* ── Page ── */
|
|
44
|
+
.vms-page { display: flex; flex-direction: column; gap: 1.5rem; color: var(--vms-text); font-family: var(--vms-font-body); }
|
|
45
|
+
.vms-page__title {
|
|
46
|
+
font-family: var(--vms-font-head);
|
|
47
|
+
font-size: 2.25rem;
|
|
48
|
+
font-weight: 400;
|
|
49
|
+
letter-spacing: -0.02em;
|
|
50
|
+
padding-bottom: 0.75rem;
|
|
51
|
+
border-bottom: 1px solid var(--vms-border);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* ── Section ── */
|
|
55
|
+
.vms-section { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
56
|
+
.vms-section__heading {
|
|
57
|
+
font-size: 11px;
|
|
58
|
+
letter-spacing: 0.08em;
|
|
59
|
+
text-transform: uppercase;
|
|
60
|
+
color: var(--vms-text-muted);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* ── Stat bar ── */
|
|
64
|
+
.vms-stat-bar { display: flex; gap: 1.5rem; flex-wrap: wrap; }
|
|
65
|
+
.vms-stat-bar__item { display: flex; align-items: baseline; gap: 0.4rem; }
|
|
66
|
+
.vms-stat-bar__value { color: var(--vms-accent); font-weight: 600; font-size: 16px; }
|
|
67
|
+
.vms-stat-bar__label {
|
|
68
|
+
color: var(--vms-text-muted);
|
|
69
|
+
font-size: 11px;
|
|
70
|
+
text-transform: uppercase;
|
|
71
|
+
letter-spacing: 0.05em;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ── Form ── */
|
|
75
|
+
.vms-form { display: flex; flex-direction: column; gap: 0.75rem; align-items: stretch; }
|
|
76
|
+
.vms-field { display: flex; flex-direction: column; gap: 0.3rem; }
|
|
77
|
+
.vms-field__label { font-size: 12px; color: var(--vms-text-muted); }
|
|
78
|
+
.vms-field__input {
|
|
79
|
+
width: 100%;
|
|
80
|
+
background: var(--vms-surface);
|
|
81
|
+
border: 1px solid var(--vms-border);
|
|
82
|
+
border-radius: var(--vms-radius);
|
|
83
|
+
color: var(--vms-text);
|
|
84
|
+
font-family: var(--vms-font-body);
|
|
85
|
+
font-size: 14px;
|
|
86
|
+
padding: 0.65rem 1rem;
|
|
87
|
+
outline: none;
|
|
88
|
+
transition: border-color var(--vms-t), box-shadow var(--vms-t);
|
|
89
|
+
resize: vertical;
|
|
90
|
+
/* Tells the browser whether the form-control chrome (date/time pickers,
|
|
91
|
+
select carets) is for a light or dark background. Drive from a CSS
|
|
92
|
+
variable so themes can flip it. */
|
|
93
|
+
color-scheme: var(--vms-color-scheme);
|
|
94
|
+
}
|
|
95
|
+
.vms-field__input::placeholder { color: var(--vms-text-muted); }
|
|
96
|
+
.vms-field__input:focus {
|
|
97
|
+
border-color: var(--vms-accent);
|
|
98
|
+
box-shadow: 0 0 0 3px var(--vms-accent-glow);
|
|
99
|
+
}
|
|
100
|
+
textarea.vms-field__input { min-height: 80px; }
|
|
101
|
+
select.vms-field__input { cursor: pointer; padding-right: 2.25rem; }
|
|
102
|
+
|
|
103
|
+
/* Form-collected checkbox (FieldNode inputType="checkbox") — inline label. */
|
|
104
|
+
.vms-field--checkbox { flex-direction: row; align-items: center; gap: 0.5rem; }
|
|
105
|
+
.vms-field--checkbox .vms-field__input {
|
|
106
|
+
width: 18px;
|
|
107
|
+
height: 18px;
|
|
108
|
+
padding: 0;
|
|
109
|
+
cursor: pointer;
|
|
110
|
+
accent-color: var(--vms-accent);
|
|
111
|
+
}
|
|
112
|
+
.vms-field--checkbox .vms-field__label { cursor: pointer; }
|
|
113
|
+
|
|
114
|
+
/* ── Standalone checkbox (CheckboxNode) ── */
|
|
115
|
+
.vms-checkbox { position: relative; display: inline-flex; align-items: center; flex-shrink: 0; cursor: pointer; }
|
|
116
|
+
.vms-checkbox__input { opacity: 0; position: absolute; width: 18px; height: 18px; cursor: pointer; margin: 0; }
|
|
117
|
+
.vms-checkbox__mark {
|
|
118
|
+
display: block;
|
|
119
|
+
width: 18px;
|
|
120
|
+
height: 18px;
|
|
121
|
+
border: 1.5px solid var(--vms-border);
|
|
122
|
+
border-radius: 5px;
|
|
123
|
+
background: var(--vms-surface-2);
|
|
124
|
+
transition: background var(--vms-t), border-color var(--vms-t);
|
|
125
|
+
position: relative;
|
|
126
|
+
flex-shrink: 0;
|
|
127
|
+
}
|
|
128
|
+
.vms-checkbox__input:checked + .vms-checkbox__mark { background: var(--vms-accent); border-color: var(--vms-accent); }
|
|
129
|
+
.vms-checkbox__input:checked + .vms-checkbox__mark::after {
|
|
130
|
+
content: '';
|
|
131
|
+
position: absolute;
|
|
132
|
+
left: 4px; top: 1px;
|
|
133
|
+
width: 5px; height: 9px;
|
|
134
|
+
border: 2px solid #fff;
|
|
135
|
+
border-top: none;
|
|
136
|
+
border-left: none;
|
|
137
|
+
transform: rotate(40deg);
|
|
138
|
+
}
|
|
139
|
+
.vms-checkbox__label { margin-left: 0.5rem; font-size: 13px; }
|
|
140
|
+
|
|
141
|
+
/* ── Buttons ── */
|
|
142
|
+
.vms-button {
|
|
143
|
+
background: var(--vms-surface-2);
|
|
144
|
+
border: 1px solid var(--vms-border);
|
|
145
|
+
border-radius: var(--vms-radius);
|
|
146
|
+
color: var(--vms-text);
|
|
147
|
+
cursor: pointer;
|
|
148
|
+
font-family: var(--vms-font-body);
|
|
149
|
+
font-size: 13px;
|
|
150
|
+
padding: 0.55rem 0.9rem;
|
|
151
|
+
transition: background var(--vms-t), border-color var(--vms-t), transform var(--vms-t);
|
|
152
|
+
}
|
|
153
|
+
.vms-button:hover { border-color: var(--vms-accent-dim); }
|
|
154
|
+
.vms-button:active { transform: scale(0.98); }
|
|
155
|
+
.vms-button--primary {
|
|
156
|
+
background: var(--vms-accent);
|
|
157
|
+
border-color: var(--vms-accent);
|
|
158
|
+
color: #fff;
|
|
159
|
+
padding: 0.65rem 1.25rem;
|
|
160
|
+
align-self: flex-start;
|
|
161
|
+
}
|
|
162
|
+
.vms-button--primary:hover { filter: brightness(1.1); }
|
|
163
|
+
.vms-button--secondary {
|
|
164
|
+
background: transparent;
|
|
165
|
+
border-color: var(--vms-accent-dim);
|
|
166
|
+
color: var(--vms-accent);
|
|
167
|
+
font-size: 12px;
|
|
168
|
+
padding: 0.35rem 0.7rem;
|
|
169
|
+
}
|
|
170
|
+
.vms-button--secondary:hover { border-color: var(--vms-accent); background: var(--vms-accent-glow); }
|
|
171
|
+
.vms-button--danger {
|
|
172
|
+
background: transparent;
|
|
173
|
+
border: 1px solid var(--vms-border);
|
|
174
|
+
color: var(--vms-error);
|
|
175
|
+
}
|
|
176
|
+
.vms-button--danger:hover { background: var(--vms-error-glow); border-color: var(--vms-error); }
|
|
177
|
+
|
|
178
|
+
/* Inside a list item, a danger button is treated as the row's "X" affordance:
|
|
179
|
+
muted and hidden until the row is hovered, then visible and red on hover. */
|
|
180
|
+
.vms-list-item .vms-button--danger {
|
|
181
|
+
border: none;
|
|
182
|
+
color: var(--vms-text-muted);
|
|
183
|
+
opacity: 0;
|
|
184
|
+
padding: 0.25rem 0.4rem;
|
|
185
|
+
border-radius: var(--vms-radius-sm);
|
|
186
|
+
transition: opacity var(--vms-t), color var(--vms-t), background var(--vms-t);
|
|
187
|
+
}
|
|
188
|
+
.vms-list-item:hover .vms-button--danger { opacity: 1; }
|
|
189
|
+
.vms-list-item .vms-button--danger:hover { color: var(--vms-error); background: var(--vms-error-glow); opacity: 1; }
|
|
190
|
+
|
|
191
|
+
/* ── Tabs ── */
|
|
192
|
+
.vms-tabs {
|
|
193
|
+
display: flex;
|
|
194
|
+
gap: 0.25rem;
|
|
195
|
+
background: var(--vms-surface);
|
|
196
|
+
border: 1px solid var(--vms-border);
|
|
197
|
+
border-radius: var(--vms-radius);
|
|
198
|
+
padding: 0.25rem;
|
|
199
|
+
}
|
|
200
|
+
.vms-tabs__tab {
|
|
201
|
+
flex: 1;
|
|
202
|
+
background: transparent;
|
|
203
|
+
border: none;
|
|
204
|
+
border-radius: var(--vms-radius-sm);
|
|
205
|
+
color: var(--vms-text-muted);
|
|
206
|
+
cursor: pointer;
|
|
207
|
+
font-family: var(--vms-font-body);
|
|
208
|
+
font-size: 12px;
|
|
209
|
+
letter-spacing: 0.05em;
|
|
210
|
+
padding: 0.4rem 0;
|
|
211
|
+
text-transform: uppercase;
|
|
212
|
+
transition: background var(--vms-t), color var(--vms-t);
|
|
213
|
+
}
|
|
214
|
+
.vms-tabs__tab:hover { color: var(--vms-text); }
|
|
215
|
+
.vms-tabs__tab--active { background: var(--vms-surface-2); color: var(--vms-accent); }
|
|
216
|
+
|
|
217
|
+
/* ── List ── */
|
|
218
|
+
.vms-list { list-style: none; display: flex; flex-direction: column; gap: 0.375rem; padding: 0; margin: 0; }
|
|
219
|
+
.vms-list-item {
|
|
220
|
+
display: flex;
|
|
221
|
+
align-items: center;
|
|
222
|
+
gap: 0.75rem;
|
|
223
|
+
background: var(--vms-surface);
|
|
224
|
+
border: 1px solid var(--vms-border);
|
|
225
|
+
border-radius: var(--vms-radius);
|
|
226
|
+
padding: 0.75rem 1rem;
|
|
227
|
+
transition: border-color var(--vms-t), background var(--vms-t);
|
|
228
|
+
}
|
|
229
|
+
.vms-list-item:hover { border-color: var(--vms-accent-dim); }
|
|
230
|
+
|
|
231
|
+
/* Common variants — apps can add more by writing .vms-list-item--{name} rules.
|
|
232
|
+
Variant accents go on border-left and are preserved through hover. */
|
|
233
|
+
.vms-list-item--done { background: var(--vms-done-bg); border-color: transparent; opacity: 0.75; }
|
|
234
|
+
.vms-list-item--critical { border-left: 3px solid var(--vms-error); background: rgba(224, 90, 90, 0.04); }
|
|
235
|
+
.vms-list-item--high { border-left: 3px solid var(--vms-priority-high); }
|
|
236
|
+
.vms-list-item--warning { border-left: 3px solid var(--vms-warning); }
|
|
237
|
+
.vms-list-item--success { border-left: 3px solid var(--vms-success); }
|
|
238
|
+
.vms-list-item--info { border-left: 3px solid var(--vms-info); }
|
|
239
|
+
|
|
240
|
+
.vms-list-item--critical:hover { border-left-color: var(--vms-error); }
|
|
241
|
+
.vms-list-item--high:hover { border-left-color: var(--vms-priority-high); }
|
|
242
|
+
.vms-list-item--warning:hover { border-left-color: var(--vms-warning); }
|
|
243
|
+
.vms-list-item--success:hover { border-left-color: var(--vms-success); }
|
|
244
|
+
.vms-list-item--info:hover { border-left-color: var(--vms-info); }
|
|
245
|
+
|
|
246
|
+
@keyframes vms-in {
|
|
247
|
+
from { opacity: 0; transform: translateY(4px); }
|
|
248
|
+
to { opacity: 1; transform: translateY(0); }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/* ── Text ── */
|
|
252
|
+
.vms-text { flex: 1; line-height: 1.4; word-break: break-word; }
|
|
253
|
+
.vms-text--heading { font-family: var(--vms-font-head); font-size: 1.5rem; }
|
|
254
|
+
.vms-text--subheading { font-size: 1.05rem; font-weight: 600; }
|
|
255
|
+
.vms-text--body { line-height: 1.6; }
|
|
256
|
+
.vms-text--muted { color: var(--vms-text-muted); font-size: 12px; }
|
|
257
|
+
.vms-text--strikethrough { color: var(--vms-done-text); text-decoration: line-through; }
|
|
258
|
+
.vms-text--error { color: var(--vms-error); font-size: 13px; }
|
|
259
|
+
.vms-text--pre { font-family: var(--vms-font-body); white-space: pre; }
|
|
260
|
+
|
|
261
|
+
/* ── Link ── */
|
|
262
|
+
.vms-link {
|
|
263
|
+
color: var(--vms-accent);
|
|
264
|
+
text-decoration: none;
|
|
265
|
+
border-bottom: 1px solid transparent;
|
|
266
|
+
transition: border-color var(--vms-t);
|
|
267
|
+
/* Hug the text inside flex containers so the underline doesn't span the
|
|
268
|
+
full row width on hover. */
|
|
269
|
+
display: inline-block;
|
|
270
|
+
align-self: flex-start;
|
|
271
|
+
}
|
|
272
|
+
.vms-link:hover { border-bottom-color: var(--vms-accent); }
|
|
273
|
+
|
|
274
|
+
/* ── Progress ── */
|
|
275
|
+
.vms-progress { height: 3px; background: var(--vms-surface-2); border-radius: 99px; overflow: hidden; }
|
|
276
|
+
.vms-progress__bar { height: 100%; background: var(--vms-accent); border-radius: 99px; transition: width 0.3s ease; }
|
|
277
|
+
|
|
278
|
+
/* ── Modal ── */
|
|
279
|
+
.vms-modal-backdrop {
|
|
280
|
+
position: fixed;
|
|
281
|
+
inset: 0;
|
|
282
|
+
background: rgba(0, 0, 0, 0.6);
|
|
283
|
+
display: flex;
|
|
284
|
+
align-items: center;
|
|
285
|
+
justify-content: center;
|
|
286
|
+
padding: 1rem;
|
|
287
|
+
z-index: 1000;
|
|
288
|
+
animation: vms-in 0.15s ease;
|
|
289
|
+
}
|
|
290
|
+
.vms-modal {
|
|
291
|
+
background: var(--vms-surface);
|
|
292
|
+
border: 1px solid var(--vms-border);
|
|
293
|
+
border-radius: var(--vms-radius);
|
|
294
|
+
width: 100%;
|
|
295
|
+
max-width: 520px;
|
|
296
|
+
max-height: 80vh;
|
|
297
|
+
display: flex;
|
|
298
|
+
flex-direction: column;
|
|
299
|
+
}
|
|
300
|
+
.vms-modal__header {
|
|
301
|
+
display: flex;
|
|
302
|
+
align-items: center;
|
|
303
|
+
justify-content: space-between;
|
|
304
|
+
padding: 1rem 1.25rem;
|
|
305
|
+
border-bottom: 1px solid var(--vms-border);
|
|
306
|
+
}
|
|
307
|
+
.vms-modal__title { font-family: var(--vms-font-head); font-size: 1.25rem; }
|
|
308
|
+
.vms-modal__close {
|
|
309
|
+
background: transparent;
|
|
310
|
+
border: none;
|
|
311
|
+
color: var(--vms-text-muted);
|
|
312
|
+
cursor: pointer;
|
|
313
|
+
font-size: 1.1rem;
|
|
314
|
+
padding: 0.25rem 0.5rem;
|
|
315
|
+
}
|
|
316
|
+
.vms-modal__close:hover { color: var(--vms-text); }
|
|
317
|
+
.vms-modal__body {
|
|
318
|
+
padding: 1.25rem;
|
|
319
|
+
overflow-y: auto;
|
|
320
|
+
display: flex;
|
|
321
|
+
flex-direction: column;
|
|
322
|
+
gap: 0.75rem;
|
|
323
|
+
}
|
|
324
|
+
.vms-modal__footer {
|
|
325
|
+
display: flex;
|
|
326
|
+
justify-content: flex-end;
|
|
327
|
+
gap: 0.5rem;
|
|
328
|
+
padding: 0.75rem 1.25rem;
|
|
329
|
+
border-top: 1px solid var(--vms-border);
|
|
330
|
+
}
|
|
331
|
+
/* Buttons inside the footer ignore the framework's default align-self so
|
|
332
|
+
they sit naturally on the footer row. */
|
|
333
|
+
.vms-modal__footer .vms-button--primary { align-self: auto; padding: 0.55rem 1rem; }
|
|
334
|
+
|
|
335
|
+
/* ── Table ── */
|
|
336
|
+
.vms-table-wrapper {
|
|
337
|
+
border: 1px solid var(--vms-border);
|
|
338
|
+
border-radius: var(--vms-radius);
|
|
339
|
+
overflow: hidden;
|
|
340
|
+
}
|
|
341
|
+
.vms-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
342
|
+
.vms-table__th {
|
|
343
|
+
background: var(--vms-surface-2);
|
|
344
|
+
color: var(--vms-text-muted);
|
|
345
|
+
font-weight: 600;
|
|
346
|
+
font-size: 11px;
|
|
347
|
+
text-transform: uppercase;
|
|
348
|
+
letter-spacing: 0.05em;
|
|
349
|
+
padding: 0.6rem 0.9rem;
|
|
350
|
+
text-align: left;
|
|
351
|
+
border-bottom: 1px solid var(--vms-border);
|
|
352
|
+
}
|
|
353
|
+
.vms-table__th--sortable { cursor: pointer; user-select: none; transition: color var(--vms-t); }
|
|
354
|
+
.vms-table__th--sortable:hover { color: var(--vms-text); }
|
|
355
|
+
.vms-table__th--asc::after { content: ' ▲'; color: var(--vms-accent); }
|
|
356
|
+
.vms-table__th--desc::after { content: ' ▼'; color: var(--vms-accent); }
|
|
357
|
+
.vms-table__filter-row th { padding: 0.4rem 0.5rem; background: var(--vms-surface); border-bottom: 1px solid var(--vms-border); }
|
|
358
|
+
.vms-table__filter-input {
|
|
359
|
+
width: 100%;
|
|
360
|
+
background: var(--vms-surface-2);
|
|
361
|
+
border: 1px solid var(--vms-border);
|
|
362
|
+
border-radius: var(--vms-radius-sm);
|
|
363
|
+
color: var(--vms-text);
|
|
364
|
+
font-family: var(--vms-font-body);
|
|
365
|
+
font-size: 12px;
|
|
366
|
+
padding: 0.3rem 0.5rem;
|
|
367
|
+
outline: none;
|
|
368
|
+
}
|
|
369
|
+
.vms-table__filter-input:focus { border-color: var(--vms-accent); }
|
|
370
|
+
.vms-table__row { transition: background var(--vms-t); }
|
|
371
|
+
.vms-table__row:not(:last-child) .vms-table__td { border-bottom: 1px solid var(--vms-border); }
|
|
372
|
+
.vms-table__row--clickable { cursor: pointer; }
|
|
373
|
+
.vms-table__row--clickable:hover { background: var(--vms-surface); }
|
|
374
|
+
.vms-table__row--done { opacity: 0.6; }
|
|
375
|
+
.vms-table__row--warning { background: rgba(212, 130, 26, 0.06); }
|
|
376
|
+
.vms-table__row--critical { background: rgba(224, 90, 90, 0.06); }
|
|
377
|
+
.vms-table__td { padding: 0.6rem 0.9rem; vertical-align: middle; }
|
|
378
|
+
.vms-table__link { color: var(--vms-accent); text-decoration: none; }
|
|
379
|
+
.vms-table__link:hover { text-decoration: underline; }
|
|
380
|
+
|
|
381
|
+
/* ── Recommended error-banner pattern ──
|
|
382
|
+
Apps typically render this from the `onError` callback. Not emitted by
|
|
383
|
+
the framework; included here so apps can use the convention without
|
|
384
|
+
re-authoring CSS. */
|
|
385
|
+
.vms-error {
|
|
386
|
+
background: var(--vms-error-glow);
|
|
387
|
+
border: 1px solid var(--vms-error);
|
|
388
|
+
border-radius: var(--vms-radius);
|
|
389
|
+
color: var(--vms-error);
|
|
390
|
+
display: flex;
|
|
391
|
+
align-items: center;
|
|
392
|
+
justify-content: space-between;
|
|
393
|
+
font-size: 13px;
|
|
394
|
+
margin-bottom: 1rem;
|
|
395
|
+
padding: 0.65rem 1rem;
|
|
396
|
+
}
|
|
397
|
+
.vms-error button {
|
|
398
|
+
background: none;
|
|
399
|
+
border: none;
|
|
400
|
+
color: inherit;
|
|
401
|
+
cursor: pointer;
|
|
402
|
+
font-size: 16px;
|
|
403
|
+
}
|