webspresso 0.0.77 → 0.0.79

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.
Files changed (37) hide show
  1. package/README.md +5 -0
  2. package/bin/commands/db-scaffold.js +81 -0
  3. package/bin/utils/model-migrations.js +211 -0
  4. package/bin/webspresso.js +2 -0
  5. package/core/content/cache.js +64 -0
  6. package/core/content/field-types.js +180 -0
  7. package/core/content/index.js +30 -0
  8. package/core/content/renderer.js +84 -0
  9. package/core/content/schema.js +75 -0
  10. package/core/content/service.js +400 -0
  11. package/core/content/types.js +59 -0
  12. package/index.d.ts +17 -0
  13. package/index.js +7 -0
  14. package/package.json +1 -1
  15. package/plugins/admin-panel/app.js +7 -7
  16. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +41 -0
  17. package/plugins/admin-panel/client/parts/04-field-renderers.js +166 -0
  18. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +32 -1
  19. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +2 -2
  20. package/plugins/admin-panel/client/parts/07-model-list.js +11 -1
  21. package/plugins/admin-panel/client/parts/08-record-list.js +1 -1
  22. package/plugins/admin-panel/index.js +46 -18
  23. package/plugins/admin-panel/modules/menu.js +1 -0
  24. package/plugins/content/admin/content-entries-component.js +291 -0
  25. package/plugins/content/admin/content-types-component.js +250 -0
  26. package/plugins/content/api-handlers.js +157 -0
  27. package/plugins/content/client/inline-edit.css +296 -0
  28. package/plugins/content/client/inline-edit.js +366 -0
  29. package/plugins/content/helpers.js +77 -0
  30. package/plugins/content/index.js +231 -0
  31. package/plugins/content/migration-template.js +54 -0
  32. package/plugins/content/models/content-entry.js +45 -0
  33. package/plugins/content/models/content-type.js +36 -0
  34. package/plugins/index.js +2 -0
  35. package/src/file-router.js +21 -1
  36. package/templates/skills/webspresso-usage/REFERENCE-framework.md +1 -1
  37. package/templates/skills/webspresso-usage/SKILL.md +5 -0
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Content types admin page — Mithril component source.
3
+ * @module plugins/content/admin/content-types-component
4
+ */
5
+
6
+ /**
7
+ * @param {Object} [options]
8
+ * @param {string} [options.apiPrefix='/content']
9
+ */
10
+ function generateContentTypesComponent(options = {}) {
11
+ const apiPrefix = options.apiPrefix || '/content';
12
+
13
+ return `
14
+ (function() {
15
+ var API = '${apiPrefix}';
16
+ var FIELD_TYPES = ['text','textarea','rich-text','number','boolean','image','url','date','select','repeater'];
17
+
18
+ function emptyField() {
19
+ return { name: '', type: 'text', label: '', required: false, options: [], fields: [] };
20
+ }
21
+
22
+ function ContentTypesListPage() {
23
+ var types = [];
24
+ var loading = true;
25
+ var error = null;
26
+
27
+ function load() {
28
+ loading = true;
29
+ error = null;
30
+ return api.get(API + '/types').then(function(res) {
31
+ types = res.data || [];
32
+ loading = false;
33
+ }).catch(function(e) {
34
+ error = e.message || String(e);
35
+ loading = false;
36
+ });
37
+ }
38
+
39
+ return {
40
+ oninit: load,
41
+ view: function() {
42
+ return m(Layout, { breadcrumbs: [{ label: 'Content Types', href: '/content/types' }] }, [
43
+ m('.flex.items-center.justify-between.mb-6', [
44
+ m('h2.text-2xl.font-bold.text-gray-900.dark:text-slate-100', 'Content Types'),
45
+ m('a.bg-blue-600.text-white.px-4.py-2.rounded-lg.text-sm', { href: '/content/types/new' }, 'New Type'),
46
+ ]),
47
+ loading ? m('p.text-gray-500', 'Loading…') : null,
48
+ error ? m('.bg-red-50.text-red-700.p-4.rounded', error) : null,
49
+ !loading && !error ? m('.bg-white.dark:bg-slate-800.rounded-lg.shadow.overflow-hidden', [
50
+ m('table.w-full.text-sm', [
51
+ m('thead.bg-gray-50.dark:bg-slate-900', m('tr', [
52
+ m('th.text-left.p-3', 'Name'),
53
+ m('th.text-left.p-3', 'Slug'),
54
+ m('th.text-left.p-3', 'Fields'),
55
+ m('th.text-right.p-3', 'Actions'),
56
+ ])),
57
+ m('tbody', types.map(function(t) {
58
+ return m('tr.border-t.border-gray-100.dark:border-slate-700', [
59
+ m('td.p-3.font-medium', t.name),
60
+ m('td.p-3.text-gray-500', t.slug),
61
+ m('td.p-3.text-gray-500', (t.schema && t.schema.fields ? t.schema.fields.length : 0)),
62
+ m('td.p-3.text-right.space-x-2', [
63
+ m('a.text-blue-600', { href: '/content/types/' + encodeURIComponent(t.slug) + '/entries' }, 'Entries'),
64
+ m('a.text-gray-600', { href: '/content/types/edit/' + t.id }, 'Edit'),
65
+ ]),
66
+ ]);
67
+ })),
68
+ ]),
69
+ types.length === 0 ? m('p.p-6.text-gray-500', 'No content types yet.') : null,
70
+ ]) : null,
71
+ ]);
72
+ },
73
+ };
74
+ }
75
+
76
+ function ContentTypeFormPage() {
77
+ var id = m.route.param('id');
78
+ var isNew = !id || id === 'new';
79
+ var form = { slug: '', name: '', description: '', schema: { fields: [emptyField()] } };
80
+ var loading = !isNew;
81
+ var saving = false;
82
+ var error = null;
83
+
84
+ function load() {
85
+ if (isNew) return Promise.resolve();
86
+ return api.get(API + '/types/' + id).then(function(res) {
87
+ var d = res.data;
88
+ form.slug = d.slug;
89
+ form.name = d.name;
90
+ form.description = d.description || '';
91
+ form.schema = d.schema || { fields: [] };
92
+ if (!form.schema.fields || form.schema.fields.length === 0) {
93
+ form.schema.fields = [emptyField()];
94
+ }
95
+ loading = false;
96
+ }).catch(function(e) {
97
+ error = e.message;
98
+ loading = false;
99
+ });
100
+ }
101
+
102
+ function renderFieldEditor(field, path, onRemove) {
103
+ return m('.border.border-gray-200.dark:border-slate-600.rounded.p-4.space-y-3', [
104
+ m('.grid.grid-cols-2.gap-3', [
105
+ m('div', [
106
+ m('label.block.text-xs.text-gray-500.mb-1', 'Name'),
107
+ m('input.w-full.border.rounded.px-2.py-1', {
108
+ value: field.name,
109
+ oninput: function(e) { field.name = e.target.value; },
110
+ }),
111
+ ]),
112
+ m('div', [
113
+ m('label.block.text-xs.text-gray-500.mb-1', 'Type'),
114
+ m('select.w-full.border.rounded.px-2.py-1', {
115
+ value: field.type,
116
+ onchange: function(e) { field.type = e.target.value; },
117
+ }, FIELD_TYPES.map(function(t) { return m('option', { value: t }, t); })),
118
+ ]),
119
+ m('div', [
120
+ m('label.block.text-xs.text-gray-500.mb-1', 'Label'),
121
+ m('input.w-full.border.rounded.px-2.py-1', {
122
+ value: field.label || '',
123
+ oninput: function(e) { field.label = e.target.value; },
124
+ }),
125
+ ]),
126
+ m('div.flex.items-end', [
127
+ m('label.flex.items-center.gap-2.text-sm', [
128
+ m('input', { type: 'checkbox', checked: !!field.required, onchange: function(e) { field.required = e.target.checked; } }),
129
+ 'Required',
130
+ ]),
131
+ ]),
132
+ ]),
133
+ field.type === 'select' ? m('div', [
134
+ m('label.block.text-xs.text-gray-500.mb-1', 'Options (comma-separated)'),
135
+ m('input.w-full.border.rounded.px-2.py-1', {
136
+ value: (field.options || []).join(', '),
137
+ oninput: function(e) {
138
+ field.options = e.target.value.split(',').map(function(s) { return s.trim(); }).filter(Boolean);
139
+ },
140
+ }),
141
+ ]) : null,
142
+ field.type === 'repeater' ? m('.pl-4.border-l-2.border-blue-200.space-y-2', [
143
+ m('p.text-xs.text-gray-500', 'Nested fields'),
144
+ (field.fields || []).map(function(nf, idx) {
145
+ return renderFieldEditor(nf, path + '.fields.' + idx, function() {
146
+ field.fields.splice(idx, 1);
147
+ });
148
+ }),
149
+ m('button.text-sm.text-blue-600', {
150
+ type: 'button',
151
+ onclick: function() {
152
+ field.fields = field.fields || [];
153
+ field.fields.push(emptyField());
154
+ },
155
+ }, '+ Add nested field'),
156
+ ]) : null,
157
+ onRemove ? m('button.text-sm.text-red-600', { type: 'button', onclick: onRemove }, 'Remove field') : null,
158
+ ]);
159
+ }
160
+
161
+ return {
162
+ oninit: load,
163
+ view: function() {
164
+ if (loading) return m(Layout, m('p.p-8.text-gray-500', 'Loading…'));
165
+ return m(Layout, { breadcrumbs: [
166
+ { label: 'Content Types', href: '/content/types' },
167
+ { label: isNew ? 'New' : 'Edit', href: '#' },
168
+ ]}, [
169
+ m('h2.text-2xl.font-bold.mb-6', isNew ? 'New Content Type' : 'Edit Content Type'),
170
+ error ? m('.bg-red-50.text-red-700.p-4.rounded.mb-4', error) : null,
171
+ m('form.bg-white.dark:bg-slate-800.rounded-lg.shadow.p-6.space-y-4', {
172
+ onsubmit: function(e) {
173
+ e.preventDefault();
174
+ saving = true;
175
+ error = null;
176
+ var payload = {
177
+ slug: form.slug,
178
+ name: form.name,
179
+ description: form.description || null,
180
+ schema: form.schema,
181
+ };
182
+ var req = isNew
183
+ ? api.post(API + '/types', payload)
184
+ : api.put(API + '/types/' + id, payload);
185
+ req.then(function() {
186
+ m.route.set('/content/types');
187
+ }).catch(function(err) {
188
+ error = err.message;
189
+ saving = false;
190
+ m.redraw();
191
+ });
192
+ },
193
+ }, [
194
+ m('.grid.grid-cols-2.gap-4', [
195
+ m('div', [
196
+ m('label.block.text-sm.font-medium.mb-1', 'Slug'),
197
+ m('input.w-full.border.rounded.px-3.py-2', {
198
+ value: form.slug,
199
+ disabled: !isNew,
200
+ oninput: function(e) { form.slug = e.target.value; },
201
+ }),
202
+ ]),
203
+ m('div', [
204
+ m('label.block.text-sm.font-medium.mb-1', 'Name'),
205
+ m('input.w-full.border.rounded.px-3.py-2', {
206
+ value: form.name,
207
+ oninput: function(e) { form.name = e.target.value; },
208
+ }),
209
+ ]),
210
+ ]),
211
+ m('div', [
212
+ m('label.block.text-sm.font-medium.mb-1', 'Description'),
213
+ m('textarea.w-full.border.rounded.px-3.py-2', {
214
+ value: form.description,
215
+ oninput: function(e) { form.description = e.target.value; },
216
+ }),
217
+ ]),
218
+ m('div', [
219
+ m('.flex.items-center.justify-between.mb-2', [
220
+ m('h3.font-semibold', 'Fields'),
221
+ m('button.text-sm.text-blue-600', {
222
+ type: 'button',
223
+ onclick: function() { form.schema.fields.push(emptyField()); },
224
+ }, '+ Add field'),
225
+ ]),
226
+ form.schema.fields.map(function(f, i) {
227
+ return renderFieldEditor(f, String(i), function() {
228
+ form.schema.fields.splice(i, 1);
229
+ });
230
+ }),
231
+ ]),
232
+ m('.flex.justify-end.gap-3.pt-4', [
233
+ m('a.px-4.py-2.border.rounded', { href: '/content/types' }, 'Cancel'),
234
+ m('button.px-4.py-2.bg-blue-600.text-white.rounded', { disabled: saving }, saving ? 'Saving…' : 'Save'),
235
+ ]),
236
+ ]),
237
+ ]);
238
+ },
239
+ };
240
+ }
241
+
242
+ window.__customPages = window.__customPages || {};
243
+ window.__customPages['content-types'] = ContentTypesListPage;
244
+ window.__customPages['content-types-new'] = ContentTypeFormPage;
245
+ window.__customPages['content-types-edit'] = ContentTypeFormPage;
246
+ })();
247
+ `;
248
+ }
249
+
250
+ module.exports = { generateContentTypesComponent };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Content plugin API handlers
3
+ * @module plugins/content/api-handlers
4
+ */
5
+
6
+ /**
7
+ * @param {Object} deps
8
+ * @param {ReturnType<import('../../core/content/service').createContentService>} deps.contentService
9
+ */
10
+ function createContentApiHandlers({ contentService }) {
11
+ function sendError(res, err, status = 400) {
12
+ const message = err?.message || 'Request failed';
13
+ if (message.includes('not found')) {
14
+ return res.status(404).json({ error: message });
15
+ }
16
+ return res.status(status).json({ error: message });
17
+ }
18
+
19
+ async function listTypesHandler(req, res) {
20
+ try {
21
+ const data = await contentService.listTypes();
22
+ res.json({ data });
23
+ } catch (err) {
24
+ sendError(res, err);
25
+ }
26
+ }
27
+
28
+ async function createTypeHandler(req, res) {
29
+ try {
30
+ const row = await contentService.createType(req.body);
31
+ res.status(201).json({ data: row });
32
+ } catch (err) {
33
+ sendError(res, err);
34
+ }
35
+ }
36
+
37
+ async function getTypeHandler(req, res) {
38
+ try {
39
+ const row = await contentService.getTypeById(req.params.id);
40
+ if (!row) return res.status(404).json({ error: 'Content type not found' });
41
+ res.json({ data: row });
42
+ } catch (err) {
43
+ sendError(res, err);
44
+ }
45
+ }
46
+
47
+ async function updateTypeHandler(req, res) {
48
+ try {
49
+ const row = await contentService.updateType(req.params.id, req.body);
50
+ res.json({ data: row });
51
+ } catch (err) {
52
+ sendError(res, err);
53
+ }
54
+ }
55
+
56
+ async function deleteTypeHandler(req, res) {
57
+ try {
58
+ await contentService.deleteType(req.params.id);
59
+ res.json({ success: true });
60
+ } catch (err) {
61
+ sendError(res, err);
62
+ }
63
+ }
64
+
65
+ async function getTypeSchemaHandler(req, res) {
66
+ try {
67
+ const row = await contentService.getTypeBySlug(req.params.typeSlug);
68
+ if (!row) return res.status(404).json({ error: 'Content type not found' });
69
+ res.json({ data: { slug: row.slug, name: row.name, schema: row.schema } });
70
+ } catch (err) {
71
+ sendError(res, err);
72
+ }
73
+ }
74
+
75
+ async function listEntriesHandler(req, res) {
76
+ try {
77
+ const data = await contentService.listEntries(req.params.typeSlug, {
78
+ status: req.query.status,
79
+ locale: req.query.locale !== undefined ? req.query.locale : undefined,
80
+ });
81
+ res.json({ data });
82
+ } catch (err) {
83
+ sendError(res, err);
84
+ }
85
+ }
86
+
87
+ async function createEntryHandler(req, res) {
88
+ try {
89
+ const row = await contentService.createEntry(req.params.typeSlug, req.body);
90
+ res.status(201).json({ data: row });
91
+ } catch (err) {
92
+ sendError(res, err);
93
+ }
94
+ }
95
+
96
+ async function getEntryHandler(req, res) {
97
+ try {
98
+ const bundle = await contentService.getEntryById(req.params.id);
99
+ if (!bundle) return res.status(404).json({ error: 'Content entry not found' });
100
+ res.json({ data: bundle.entry, type: bundle.type });
101
+ } catch (err) {
102
+ sendError(res, err);
103
+ }
104
+ }
105
+
106
+ async function updateEntryHandler(req, res) {
107
+ try {
108
+ const row = await contentService.updateEntry(req.params.id, req.body);
109
+ res.json({ data: row });
110
+ } catch (err) {
111
+ sendError(res, err);
112
+ }
113
+ }
114
+
115
+ async function deleteEntryHandler(req, res) {
116
+ try {
117
+ await contentService.deleteEntry(req.params.id);
118
+ res.json({ success: true });
119
+ } catch (err) {
120
+ sendError(res, err);
121
+ }
122
+ }
123
+
124
+ async function publicGetEntryHandler(req, res) {
125
+ try {
126
+ const result = await contentService.getEntry(req.params.typeSlug, req.params.entrySlug, {
127
+ locale: req.query.locale ?? null,
128
+ });
129
+ if (!result) return res.status(404).json({ error: 'Content not found' });
130
+ res.json({
131
+ type: result.meta.typeSlug,
132
+ slug: result.meta.slug,
133
+ data: result.data,
134
+ meta: result.meta,
135
+ });
136
+ } catch (err) {
137
+ sendError(res, err);
138
+ }
139
+ }
140
+
141
+ return {
142
+ listTypesHandler,
143
+ createTypeHandler,
144
+ getTypeHandler,
145
+ updateTypeHandler,
146
+ deleteTypeHandler,
147
+ getTypeSchemaHandler,
148
+ listEntriesHandler,
149
+ createEntryHandler,
150
+ getEntryHandler,
151
+ updateEntryHandler,
152
+ deleteEntryHandler,
153
+ publicGetEntryHandler,
154
+ };
155
+ }
156
+
157
+ module.exports = { createContentApiHandlers };
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Inline edit — popover editor for admin users on public pages.
3
+ */
4
+ .ws-content-host,
5
+ .ws-content-block {
6
+ position: relative;
7
+ margin-block: 0.25rem;
8
+ }
9
+
10
+ .ws-content-toolbar {
11
+ display: flex;
12
+ justify-content: flex-end;
13
+ margin-bottom: 0.375rem;
14
+ min-height: 1.75rem;
15
+ }
16
+
17
+ .ws-content-edit-btn {
18
+ display: inline-flex;
19
+ align-items: center;
20
+ gap: 0.35rem;
21
+ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
22
+ font-size: 0.75rem;
23
+ font-weight: 600;
24
+ line-height: 1;
25
+ padding: 0.35rem 0.65rem;
26
+ border-radius: 9999px;
27
+ border: 1px solid #bfdbfe;
28
+ background: #eff6ff;
29
+ color: #1d4ed8;
30
+ cursor: pointer;
31
+ box-shadow: 0 1px 2px rgba(37, 99, 235, 0.12);
32
+ transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
33
+ }
34
+
35
+ .ws-content-edit-btn:hover {
36
+ background: #dbeafe;
37
+ border-color: #93c5fd;
38
+ box-shadow: 0 2px 6px rgba(37, 99, 235, 0.18);
39
+ }
40
+
41
+ .ws-content-edit-btn:focus-visible {
42
+ outline: 2px solid #2563eb;
43
+ outline-offset: 2px;
44
+ }
45
+
46
+ .ws-content-edit-btn-icon {
47
+ font-size: 0.85em;
48
+ line-height: 1;
49
+ }
50
+
51
+ .ws-content-editable {
52
+ outline: 1px dashed transparent;
53
+ outline-offset: 3px;
54
+ border-radius: 0.2rem;
55
+ transition: outline-color 0.15s, background-color 0.15s;
56
+ }
57
+
58
+ .ws-content-host:hover .ws-content-editable,
59
+ .ws-content-host.is-editing .ws-content-editable {
60
+ outline-color: rgba(37, 99, 235, 0.4);
61
+ background: rgba(37, 99, 235, 0.05);
62
+ }
63
+
64
+ /* Editor shell — id kept for compatibility */
65
+ #ws-content-modal {
66
+ display: none;
67
+ position: fixed;
68
+ inset: 0;
69
+ z-index: 99999;
70
+ pointer-events: none;
71
+ }
72
+
73
+ #ws-content-modal.is-open {
74
+ display: block;
75
+ }
76
+
77
+ .ws-content-modal-backdrop {
78
+ position: absolute;
79
+ inset: 0;
80
+ background: rgba(15, 23, 42, 0.2);
81
+ pointer-events: auto;
82
+ }
83
+
84
+ .ws-content-modal-panel {
85
+ position: fixed;
86
+ z-index: 1;
87
+ width: min(22rem, calc(100vw - 1.5rem));
88
+ max-height: min(24rem, calc(100vh - 2rem));
89
+ display: flex;
90
+ flex-direction: column;
91
+ background: #fff;
92
+ border: 1px solid #e2e8f0;
93
+ border-radius: 0.75rem;
94
+ box-shadow:
95
+ 0 4px 6px -1px rgba(15, 23, 42, 0.08),
96
+ 0 16px 32px -8px rgba(15, 23, 42, 0.18);
97
+ pointer-events: auto;
98
+ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
99
+ font-size: 0.875rem;
100
+ color: #0f172a;
101
+ box-sizing: border-box;
102
+ }
103
+
104
+ .ws-content-modal-panel *,
105
+ .ws-content-modal-panel *::before,
106
+ .ws-content-modal-panel *::after {
107
+ box-sizing: border-box;
108
+ }
109
+
110
+ .ws-content-popover-arrow {
111
+ position: absolute;
112
+ width: 12px;
113
+ height: 12px;
114
+ background: #fff;
115
+ border: 1px solid #e2e8f0;
116
+ transform: rotate(45deg);
117
+ top: -7px;
118
+ left: 50%;
119
+ margin-left: -6px;
120
+ pointer-events: none;
121
+ }
122
+
123
+ .ws-content-modal-panel.is-above .ws-content-popover-arrow {
124
+ top: auto;
125
+ bottom: -7px;
126
+ border-top-color: transparent;
127
+ border-left-color: transparent;
128
+ }
129
+
130
+ .ws-content-modal-panel:not(.is-above) .ws-content-popover-arrow {
131
+ border-bottom-color: transparent;
132
+ border-right-color: transparent;
133
+ }
134
+
135
+ .ws-content-modal-header {
136
+ display: flex;
137
+ align-items: center;
138
+ justify-content: space-between;
139
+ gap: 0.75rem;
140
+ padding: 0.75rem 1rem;
141
+ border-bottom: 1px solid #f1f5f9;
142
+ flex-shrink: 0;
143
+ }
144
+
145
+ .ws-content-modal-title {
146
+ margin: 0;
147
+ font-size: 0.875rem;
148
+ font-weight: 600;
149
+ line-height: 1.3;
150
+ color: #0f172a;
151
+ }
152
+
153
+ .ws-content-modal-close {
154
+ flex-shrink: 0;
155
+ width: 1.75rem;
156
+ height: 1.75rem;
157
+ display: inline-flex;
158
+ align-items: center;
159
+ justify-content: center;
160
+ border: none;
161
+ border-radius: 0.375rem;
162
+ background: transparent;
163
+ font-size: 1.25rem;
164
+ line-height: 1;
165
+ cursor: pointer;
166
+ color: #64748b;
167
+ }
168
+
169
+ .ws-content-modal-close:hover {
170
+ background: #f1f5f9;
171
+ color: #334155;
172
+ }
173
+
174
+ .ws-content-modal-body {
175
+ padding: 0.75rem 1rem;
176
+ overflow: auto;
177
+ flex: 1;
178
+ min-height: 0;
179
+ }
180
+
181
+ .ws-content-field {
182
+ margin-bottom: 0.75rem;
183
+ }
184
+
185
+ .ws-content-field:last-child {
186
+ margin-bottom: 0;
187
+ }
188
+
189
+ .ws-content-field label {
190
+ display: block;
191
+ font-size: 0.75rem;
192
+ font-weight: 600;
193
+ margin-bottom: 0.3rem;
194
+ color: #475569;
195
+ letter-spacing: 0.01em;
196
+ }
197
+
198
+ .ws-content-field input,
199
+ .ws-content-field textarea,
200
+ .ws-content-field select {
201
+ width: 100%;
202
+ border: 1px solid #cbd5e1;
203
+ border-radius: 0.5rem;
204
+ padding: 0.5rem 0.625rem;
205
+ font: inherit;
206
+ font-size: 0.875rem;
207
+ color: #0f172a;
208
+ background: #fff;
209
+ transition: border-color 0.15s, box-shadow 0.15s;
210
+ }
211
+
212
+ .ws-content-field input:focus,
213
+ .ws-content-field textarea:focus,
214
+ .ws-content-field select:focus {
215
+ outline: none;
216
+ border-color: #3b82f6;
217
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
218
+ }
219
+
220
+ .ws-content-field textarea {
221
+ resize: vertical;
222
+ min-height: 4.5rem;
223
+ }
224
+
225
+ .ws-content-modal-error {
226
+ padding: 0 1rem 0.5rem;
227
+ color: #b91c1c;
228
+ font-size: 0.8125rem;
229
+ flex-shrink: 0;
230
+ }
231
+
232
+ .ws-content-modal-footer {
233
+ display: flex;
234
+ justify-content: flex-end;
235
+ gap: 0.5rem;
236
+ padding: 0.625rem 1rem 0.875rem;
237
+ border-top: 1px solid #f1f5f9;
238
+ flex-shrink: 0;
239
+ }
240
+
241
+ .ws-content-btn {
242
+ border-radius: 0.5rem;
243
+ padding: 0.45rem 0.875rem;
244
+ font-size: 0.8125rem;
245
+ font-weight: 600;
246
+ cursor: pointer;
247
+ border: 1px solid transparent;
248
+ font-family: inherit;
249
+ }
250
+
251
+ .ws-content-btn-muted {
252
+ background: #f8fafc;
253
+ border-color: #e2e8f0;
254
+ color: #475569;
255
+ }
256
+
257
+ .ws-content-btn-muted:hover {
258
+ background: #f1f5f9;
259
+ }
260
+
261
+ .ws-content-btn-primary {
262
+ background: #2563eb;
263
+ color: #fff;
264
+ }
265
+
266
+ .ws-content-btn-primary:hover:not(:disabled) {
267
+ background: #1d4ed8;
268
+ }
269
+
270
+ .ws-content-btn-primary:disabled {
271
+ opacity: 0.6;
272
+ cursor: not-allowed;
273
+ }
274
+
275
+ /* Mobile: bottom sheet */
276
+ @media (max-width: 639px) {
277
+ .ws-content-modal-panel.is-sheet {
278
+ left: 0 !important;
279
+ right: 0 !important;
280
+ bottom: 0 !important;
281
+ top: auto !important;
282
+ width: 100% !important;
283
+ max-width: none;
284
+ max-height: min(88vh, 32rem);
285
+ border-radius: 1rem 1rem 0 0;
286
+ border-bottom: none;
287
+ }
288
+
289
+ .ws-content-modal-panel.is-sheet .ws-content-popover-arrow {
290
+ display: none;
291
+ }
292
+
293
+ .ws-content-modal-backdrop {
294
+ background: rgba(15, 23, 42, 0.35);
295
+ }
296
+ }