@techninja/clearstack 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 +81 -0
- package/bin/cli.js +62 -0
- package/docs/BACKEND_API_SPEC.md +281 -0
- package/docs/BUILD_LOG.md +193 -0
- package/docs/COMPONENT_PATTERNS.md +481 -0
- package/docs/CONVENTIONS.md +226 -0
- package/docs/FRONTEND_IMPLEMENTATION_RULES.md +239 -0
- package/docs/JSDOC_TYPING.md +86 -0
- package/docs/QUICKSTART.md +190 -0
- package/docs/SERVER_AND_DEPS.md +163 -0
- package/docs/STATE_AND_ROUTING.md +363 -0
- package/docs/TESTING.md +268 -0
- package/docs/app-spec/ENTITIES.md +37 -0
- package/docs/app-spec/README.md +19 -0
- package/lib/check.js +115 -0
- package/lib/copy.js +43 -0
- package/lib/init.js +73 -0
- package/lib/package-gen.js +83 -0
- package/lib/update.js +73 -0
- package/package.json +69 -0
- package/templates/fullstack/data/seed.json +1 -0
- package/templates/fullstack/src/api/db.js +75 -0
- package/templates/fullstack/src/api/entities.js +114 -0
- package/templates/fullstack/src/api/events.js +35 -0
- package/templates/fullstack/src/api/schemas.js +104 -0
- package/templates/fullstack/src/api/validate.js +52 -0
- package/templates/fullstack/src/pages/home/home-view.js +19 -0
- package/templates/fullstack/src/router/index.js +16 -0
- package/templates/fullstack/src/server.js +46 -0
- package/templates/fullstack/src/store/AppState.js +33 -0
- package/templates/fullstack/src/store/UserPrefs.js +31 -0
- package/templates/fullstack/src/store/realtimeSync.js +54 -0
- package/templates/shared/.configs/.prettierrc +8 -0
- package/templates/shared/.configs/eslint.config.js +64 -0
- package/templates/shared/.configs/jsconfig.json +24 -0
- package/templates/shared/.configs/web-test-runner.config.js +8 -0
- package/templates/shared/.env +9 -0
- package/templates/shared/.github/ISSUE_TEMPLATE/bug_report.md +42 -0
- package/templates/shared/.github/ISSUE_TEMPLATE/feature_request.md +30 -0
- package/templates/shared/.github/ISSUE_TEMPLATE/spec_correction.md +26 -0
- package/templates/shared/.github/pull_request_template.md +51 -0
- package/templates/shared/.github/workflows/spec.yml +46 -0
- package/templates/shared/README.md +22 -0
- package/templates/shared/docs/app-spec/README.md +40 -0
- package/templates/shared/docs/clearstack/BACKEND_API_SPEC.md +281 -0
- package/templates/shared/docs/clearstack/BUILD_LOG.md +193 -0
- package/templates/shared/docs/clearstack/COMPONENT_PATTERNS.md +481 -0
- package/templates/shared/docs/clearstack/CONVENTIONS.md +226 -0
- package/templates/shared/docs/clearstack/FRONTEND_IMPLEMENTATION_RULES.md +239 -0
- package/templates/shared/docs/clearstack/JSDOC_TYPING.md +86 -0
- package/templates/shared/docs/clearstack/QUICKSTART.md +190 -0
- package/templates/shared/docs/clearstack/SERVER_AND_DEPS.md +163 -0
- package/templates/shared/docs/clearstack/STATE_AND_ROUTING.md +363 -0
- package/templates/shared/docs/clearstack/TESTING.md +268 -0
- package/templates/shared/public/index.html +26 -0
- package/templates/shared/scripts/build-icons.js +86 -0
- package/templates/shared/scripts/vendor-deps.js +25 -0
- package/templates/shared/src/components/atoms/app-badge/app-badge.css +4 -0
- package/templates/shared/src/components/atoms/app-badge/app-badge.js +23 -0
- package/templates/shared/src/components/atoms/app-badge/app-badge.test.js +26 -0
- package/templates/shared/src/components/atoms/app-badge/index.js +1 -0
- package/templates/shared/src/components/atoms/app-button/app-button.css +3 -0
- package/templates/shared/src/components/atoms/app-button/app-button.js +41 -0
- package/templates/shared/src/components/atoms/app-button/app-button.test.js +43 -0
- package/templates/shared/src/components/atoms/app-button/index.js +1 -0
- package/templates/shared/src/components/atoms/app-icon/app-icon.css +4 -0
- package/templates/shared/src/components/atoms/app-icon/app-icon.js +57 -0
- package/templates/shared/src/components/atoms/app-icon/app-icon.test.js +30 -0
- package/templates/shared/src/components/atoms/app-icon/index.js +1 -0
- package/templates/shared/src/components/atoms/theme-toggle/index.js +1 -0
- package/templates/shared/src/components/atoms/theme-toggle/theme-toggle.css +10 -0
- package/templates/shared/src/components/atoms/theme-toggle/theme-toggle.js +42 -0
- package/templates/shared/src/styles/buttons.css +79 -0
- package/templates/shared/src/styles/components.css +31 -0
- package/templates/shared/src/styles/forms.css +20 -0
- package/templates/shared/src/styles/reset.css +32 -0
- package/templates/shared/src/styles/shared.css +135 -0
- package/templates/shared/src/styles/tokens.css +65 -0
- package/templates/shared/src/utils/formatDate.js +41 -0
- package/templates/shared/src/utils/statusColors.js +60 -0
- package/templates/static/src/pages/home/home-view.js +38 -0
- package/templates/static/src/router/index.js +16 -0
- package/templates/static/src/store/AppState.js +26 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extracts SVG path data from lucide-static for icons used in the app.
|
|
5
|
+
* Generates public/icons.json — loaded by app-icon at runtime.
|
|
6
|
+
* Runs on `npm postinstall`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
10
|
+
import { resolve, dirname } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
14
|
+
const ICONS_DIR = resolve(ROOT, 'node_modules/lucide-static/icons');
|
|
15
|
+
const OUT = resolve(ROOT, 'public/icons.json');
|
|
16
|
+
|
|
17
|
+
/** Icons used in the app — lucide name → app name */
|
|
18
|
+
const ICON_MAP = {
|
|
19
|
+
plus: 'plus',
|
|
20
|
+
check: 'check',
|
|
21
|
+
'trash-2': 'trash',
|
|
22
|
+
x: 'x',
|
|
23
|
+
pencil: 'edit',
|
|
24
|
+
'chevron-right': 'chevron',
|
|
25
|
+
folder: 'folder',
|
|
26
|
+
'grip-vertical': 'grip',
|
|
27
|
+
list: 'list',
|
|
28
|
+
'layout-grid': 'grid',
|
|
29
|
+
filter: 'filter',
|
|
30
|
+
settings: 'settings',
|
|
31
|
+
'mouse-pointer': 'pointer',
|
|
32
|
+
'pen-tool': 'pen',
|
|
33
|
+
square: 'rect',
|
|
34
|
+
circle: 'circle',
|
|
35
|
+
minus: 'line',
|
|
36
|
+
shapes: 'shapes',
|
|
37
|
+
cloud: 'cloud',
|
|
38
|
+
database: 'database',
|
|
39
|
+
diamond: 'diamond',
|
|
40
|
+
hexagon: 'hexagon',
|
|
41
|
+
'file-text': 'document',
|
|
42
|
+
'arrow-right': 'arrow-right',
|
|
43
|
+
cylinder: 'cylinder',
|
|
44
|
+
server: 'server',
|
|
45
|
+
monitor: 'monitor',
|
|
46
|
+
globe: 'globe',
|
|
47
|
+
lock: 'lock',
|
|
48
|
+
shield: 'shield',
|
|
49
|
+
user: 'user',
|
|
50
|
+
cpu: 'cpu',
|
|
51
|
+
wifi: 'wifi',
|
|
52
|
+
zap: 'zap',
|
|
53
|
+
box: 'box',
|
|
54
|
+
star: 'star',
|
|
55
|
+
type: 'text',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Extract all <path>, <line>, <circle>, <rect>, <polyline> inner content from an SVG file.
|
|
60
|
+
* @param {string} file
|
|
61
|
+
* @returns {string} Combined SVG inner elements
|
|
62
|
+
*/
|
|
63
|
+
function extractInner(file) {
|
|
64
|
+
const svg = readFileSync(file, 'utf-8');
|
|
65
|
+
const inner = svg.match(/<(path|line|circle|rect|polyline|ellipse)\s[^>]*\/>/g);
|
|
66
|
+
return inner ? inner.join('') : '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
mkdirSync(dirname(OUT), { recursive: true });
|
|
70
|
+
|
|
71
|
+
/** @type {Record<string, string>} */
|
|
72
|
+
const icons = {};
|
|
73
|
+
let count = 0;
|
|
74
|
+
|
|
75
|
+
for (const [lucideName, appName] of Object.entries(ICON_MAP)) {
|
|
76
|
+
const file = resolve(ICONS_DIR, `${lucideName}.svg`);
|
|
77
|
+
if (!existsSync(file)) {
|
|
78
|
+
console.warn(`⚠ Icon not found: ${lucideName}`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
icons[appName] = extractInner(file);
|
|
82
|
+
count++;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
writeFileSync(OUT, JSON.stringify(icons, null, 2));
|
|
86
|
+
console.log(`✓ Built ${count} icons → public/icons.json`);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Copies third-party ES module sources into public/vendor/ so the browser
|
|
5
|
+
* can load them directly via import map. Runs on `npm postinstall`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { cpSync, mkdirSync } from 'node:fs';
|
|
9
|
+
import { resolve, dirname } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
13
|
+
const VENDOR_DIR = resolve(ROOT, 'public/vendor');
|
|
14
|
+
|
|
15
|
+
/** @type {{ name: string, src: string }[]} */
|
|
16
|
+
const DEPS = [{ name: 'hybrids', src: 'node_modules/hybrids/src' }];
|
|
17
|
+
|
|
18
|
+
mkdirSync(VENDOR_DIR, { recursive: true });
|
|
19
|
+
|
|
20
|
+
for (const dep of DEPS) {
|
|
21
|
+
const src = resolve(ROOT, dep.src);
|
|
22
|
+
const dest = resolve(VENDOR_DIR, dep.name);
|
|
23
|
+
cpSync(src, dest, { recursive: true });
|
|
24
|
+
console.log(`✓ Vendored: ${dep.name} → public/vendor/${dep.name}/`);
|
|
25
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Badge atom — inline status/priority indicator.
|
|
3
|
+
* @module components/atoms/app-badge
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { html, define } from 'hybrids';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {Object} AppBadgeHost
|
|
10
|
+
* @property {string} label - Badge text
|
|
11
|
+
* @property {'info'|'success'|'warning'|'danger'} color - Color variant
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** @type {import('hybrids').Component<AppBadgeHost>} */
|
|
15
|
+
export default define({
|
|
16
|
+
tag: 'app-badge',
|
|
17
|
+
label: '',
|
|
18
|
+
color: 'info',
|
|
19
|
+
render: {
|
|
20
|
+
value: ({ label, color }) => html` <span class="badge badge-${color}">${label}</span> `,
|
|
21
|
+
shadow: false,
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { fixture, expect } from '@open-wc/testing';
|
|
2
|
+
import '../app-badge/app-badge.js';
|
|
3
|
+
|
|
4
|
+
describe('app-badge', () => {
|
|
5
|
+
it('renders a span with label text', async () => {
|
|
6
|
+
const el = await fixture(`<app-badge label="Active"></app-badge>`);
|
|
7
|
+
await new Promise((r) => requestAnimationFrame(r));
|
|
8
|
+
const span = el.querySelector('.badge');
|
|
9
|
+
expect(span).to.exist;
|
|
10
|
+
expect(span.textContent).to.contain('Active');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('applies color variant class', async () => {
|
|
14
|
+
const el = await fixture(`<app-badge label="High" color="danger"></app-badge>`);
|
|
15
|
+
await new Promise((r) => requestAnimationFrame(r));
|
|
16
|
+
const span = el.querySelector('.badge');
|
|
17
|
+
expect(span.classList.contains('badge-danger')).to.be.true;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('defaults to info color', async () => {
|
|
21
|
+
const el = await fixture(`<app-badge label="Note"></app-badge>`);
|
|
22
|
+
await new Promise((r) => requestAnimationFrame(r));
|
|
23
|
+
const span = el.querySelector('.badge');
|
|
24
|
+
expect(span.classList.contains('badge-info')).to.be.true;
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './app-badge.js';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Button atom — single-purpose action element.
|
|
3
|
+
* @module components/atoms/app-button
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { html, define, dispatch } from 'hybrids';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {Object} AppButtonHost
|
|
10
|
+
* @property {string} label - Button display text
|
|
11
|
+
* @property {'primary'|'secondary'|'ghost'|'danger'} variant - Visual style
|
|
12
|
+
* @property {boolean} disabled - Disabled state
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {AppButtonHost & HTMLElement} host
|
|
17
|
+
* @param {MouseEvent} event
|
|
18
|
+
*/
|
|
19
|
+
function handleClick(host, event) {
|
|
20
|
+
if (host.disabled) {
|
|
21
|
+
event.preventDefault();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
dispatch(host, 'press', { bubbles: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @type {import('hybrids').Component<AppButtonHost>} */
|
|
28
|
+
export default define({
|
|
29
|
+
tag: 'app-button',
|
|
30
|
+
label: '',
|
|
31
|
+
variant: 'primary',
|
|
32
|
+
disabled: false,
|
|
33
|
+
render: {
|
|
34
|
+
value: ({ label, variant, disabled }) => html`
|
|
35
|
+
<button class="btn btn-${variant}" disabled="${disabled}" onclick="${handleClick}">
|
|
36
|
+
${label}
|
|
37
|
+
</button>
|
|
38
|
+
`,
|
|
39
|
+
shadow: false,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { fixture, expect, oneEvent } from '@open-wc/testing';
|
|
2
|
+
import { html } from 'hybrids';
|
|
3
|
+
import '../app-button/app-button.js';
|
|
4
|
+
|
|
5
|
+
describe('app-button', () => {
|
|
6
|
+
it('renders a button with label', async () => {
|
|
7
|
+
const el = await fixture(`<app-button label="Click me"></app-button>`);
|
|
8
|
+
await new Promise((r) => requestAnimationFrame(r));
|
|
9
|
+
const btn = el.querySelector('button');
|
|
10
|
+
expect(btn).to.exist;
|
|
11
|
+
expect(btn.textContent).to.contain('Click me');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('applies variant class', async () => {
|
|
15
|
+
const el = await fixture(`<app-button label="Go" variant="danger"></app-button>`);
|
|
16
|
+
await new Promise((r) => requestAnimationFrame(r));
|
|
17
|
+
const btn = el.querySelector('button');
|
|
18
|
+
expect(btn.classList.contains('btn-danger')).to.be.true;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('defaults to primary variant', async () => {
|
|
22
|
+
const el = await fixture(`<app-button label="Go"></app-button>`);
|
|
23
|
+
await new Promise((r) => requestAnimationFrame(r));
|
|
24
|
+
const btn = el.querySelector('button');
|
|
25
|
+
expect(btn.classList.contains('btn-primary')).to.be.true;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('reflects disabled attribute to button', async () => {
|
|
29
|
+
const el = await fixture(`<app-button label="No" disabled></app-button>`);
|
|
30
|
+
await new Promise((r) => requestAnimationFrame(r));
|
|
31
|
+
const btn = el.querySelector('button');
|
|
32
|
+
expect(btn.disabled).to.be.true;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('dispatches press event on click', async () => {
|
|
36
|
+
const el = await fixture(`<app-button label="Go"></app-button>`);
|
|
37
|
+
await new Promise((r) => requestAnimationFrame(r));
|
|
38
|
+
const btn = el.querySelector('button');
|
|
39
|
+
setTimeout(() => btn.click());
|
|
40
|
+
const event = await oneEvent(el, 'press');
|
|
41
|
+
expect(event).to.exist;
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './app-button.js';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Icon atom — renders Lucide SVG icons by name.
|
|
3
|
+
* Icons loaded from /icons.json (generated at install from lucide-static).
|
|
4
|
+
* @module components/atoms/app-icon
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { html, define } from 'hybrids';
|
|
8
|
+
|
|
9
|
+
/** @type {Record<string, string>|null} */
|
|
10
|
+
let iconCache = null;
|
|
11
|
+
|
|
12
|
+
/** @type {Promise<Record<string, string>>} */
|
|
13
|
+
const loading = fetch('/icons.json')
|
|
14
|
+
.then((r) => (r.ok ? r : fetch('/public/icons.json')))
|
|
15
|
+
.then((r) => r.json())
|
|
16
|
+
.then((data) => {
|
|
17
|
+
iconCache = data;
|
|
18
|
+
return data;
|
|
19
|
+
})
|
|
20
|
+
.catch(() => {
|
|
21
|
+
iconCache = {};
|
|
22
|
+
return {};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} AppIconHost
|
|
27
|
+
* @property {string} name
|
|
28
|
+
* @property {'sm'|'md'|'lg'} size
|
|
29
|
+
* @property {string} svgContent - Resolved SVG inner markup
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** @type {import('hybrids').Component<AppIconHost>} */
|
|
33
|
+
export default define({
|
|
34
|
+
tag: 'app-icon',
|
|
35
|
+
name: '',
|
|
36
|
+
size: 'md',
|
|
37
|
+
svgContent: {
|
|
38
|
+
value: '',
|
|
39
|
+
connect(host, _key, invalidate) {
|
|
40
|
+
loading.then(() => {
|
|
41
|
+
host.svgContent = iconCache?.[host.name] || '';
|
|
42
|
+
invalidate();
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
observe(host, val) {
|
|
46
|
+
const span = host.querySelector('.icon');
|
|
47
|
+
if (!span) return;
|
|
48
|
+
span.innerHTML = val
|
|
49
|
+
? `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${val}</svg>`
|
|
50
|
+
: '';
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
render: {
|
|
54
|
+
value: ({ size }) => html`<span class="icon icon-${size}"></span>`,
|
|
55
|
+
shadow: false,
|
|
56
|
+
},
|
|
57
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { fixture, expect } from '@open-wc/testing';
|
|
2
|
+
import '../app-icon/app-icon.js';
|
|
3
|
+
|
|
4
|
+
const frame = () => new Promise((r) => requestAnimationFrame(r));
|
|
5
|
+
|
|
6
|
+
describe('app-icon', () => {
|
|
7
|
+
it('renders a span with icon class', async () => {
|
|
8
|
+
const el = await fixture(`<app-icon name="plus"></app-icon>`);
|
|
9
|
+
await frame();
|
|
10
|
+
const span = el.querySelector('.icon');
|
|
11
|
+
expect(span).to.exist;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('applies size class', async () => {
|
|
15
|
+
const el = await fixture(`<app-icon name="check" size="lg"></app-icon>`);
|
|
16
|
+
await frame();
|
|
17
|
+
expect(el.querySelector('.icon-lg')).to.exist;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('applies sm size class', async () => {
|
|
21
|
+
const el = await fixture(`<app-icon name="check" size="sm"></app-icon>`);
|
|
22
|
+
await frame();
|
|
23
|
+
expect(el.querySelector('.icon-sm')).to.exist;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('reflects name property', async () => {
|
|
27
|
+
const el = await fixture(`<app-icon name="folder"></app-icon>`);
|
|
28
|
+
expect(el.name).to.equal('folder');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './app-icon.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './theme-toggle.js';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme toggle atom — switches between light and dark mode.
|
|
3
|
+
* Persists via AppState store (localStorage-backed).
|
|
4
|
+
* @module components/atoms/theme-toggle
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { html, define, store } from 'hybrids';
|
|
8
|
+
import AppState from '../../../store/AppState.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} ThemeToggleHost
|
|
12
|
+
* @property {import('../../../store/AppState.js').AppState} state
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {ThemeToggleHost & HTMLElement} host
|
|
17
|
+
*/
|
|
18
|
+
function toggle(host) {
|
|
19
|
+
if (!store.ready(host.state)) return;
|
|
20
|
+
const next = host.state.theme === 'light' ? 'dark' : 'light';
|
|
21
|
+
store.set(host.state, { theme: next });
|
|
22
|
+
document.documentElement.setAttribute('data-theme', next);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @type {import('hybrids').Component<ThemeToggleHost>} */
|
|
26
|
+
export default define({
|
|
27
|
+
tag: 'theme-toggle',
|
|
28
|
+
state: store(AppState),
|
|
29
|
+
render: {
|
|
30
|
+
value: ({ state }) => {
|
|
31
|
+
if (store.ready(state)) {
|
|
32
|
+
document.documentElement.setAttribute('data-theme', state.theme);
|
|
33
|
+
}
|
|
34
|
+
return html`
|
|
35
|
+
<button class="btn btn-ghost theme-toggle-btn" onclick="${toggle}">
|
|
36
|
+
${store.ready(state) && state.theme === 'light' ? '🌙' : '☀️'}
|
|
37
|
+
</button>
|
|
38
|
+
`;
|
|
39
|
+
},
|
|
40
|
+
shadow: false,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/* Global button styles — usable on any <button class="btn"> element.
|
|
2
|
+
Loaded in index.html via components.css or directly. */
|
|
3
|
+
|
|
4
|
+
.btn {
|
|
5
|
+
display: inline-flex;
|
|
6
|
+
align-items: center;
|
|
7
|
+
gap: var(--space-sm);
|
|
8
|
+
padding: var(--space-sm) var(--space-md);
|
|
9
|
+
border: none;
|
|
10
|
+
border-radius: var(--radius-md);
|
|
11
|
+
font: inherit;
|
|
12
|
+
font-size: var(--text-sm);
|
|
13
|
+
font-weight: 500;
|
|
14
|
+
cursor: pointer;
|
|
15
|
+
transition:
|
|
16
|
+
background 0.15s ease,
|
|
17
|
+
opacity 0.15s ease;
|
|
18
|
+
&:disabled {
|
|
19
|
+
opacity: 0.5;
|
|
20
|
+
cursor: not-allowed;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.btn-primary {
|
|
25
|
+
background: var(--color-primary);
|
|
26
|
+
color: var(--color-text-inverse);
|
|
27
|
+
&:hover:not(:disabled) {
|
|
28
|
+
background: var(--color-primary-hover);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.btn-secondary {
|
|
33
|
+
background: var(--color-surface);
|
|
34
|
+
color: var(--color-text);
|
|
35
|
+
border: 1px solid var(--color-border);
|
|
36
|
+
&:hover:not(:disabled) {
|
|
37
|
+
background: var(--color-bg);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.btn-ghost {
|
|
42
|
+
background: transparent;
|
|
43
|
+
color: var(--color-primary);
|
|
44
|
+
&:hover:not(:disabled) {
|
|
45
|
+
background: var(--color-bg);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.btn-success {
|
|
50
|
+
background: var(--color-success);
|
|
51
|
+
color: var(--color-text-inverse);
|
|
52
|
+
&:hover:not(:disabled) {
|
|
53
|
+
background: var(--color-success-hover);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.btn-warning {
|
|
58
|
+
background: var(--color-warning);
|
|
59
|
+
color: var(--color-text-inverse);
|
|
60
|
+
&:hover:not(:disabled) {
|
|
61
|
+
background: var(--color-warning-hover);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.btn-danger {
|
|
66
|
+
background: var(--color-danger);
|
|
67
|
+
color: var(--color-text-inverse);
|
|
68
|
+
&:hover:not(:disabled) {
|
|
69
|
+
background: var(--color-danger-hover);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.btn-info {
|
|
74
|
+
background: var(--color-info);
|
|
75
|
+
color: var(--color-text-inverse);
|
|
76
|
+
&:hover:not(:disabled) {
|
|
77
|
+
background: var(--color-info-hover);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/* Component styles aggregator.
|
|
2
|
+
Add one @import per component as they are created.
|
|
3
|
+
Loaded once in index.html — all light DOM components inherit these. */
|
|
4
|
+
|
|
5
|
+
/* Atoms */
|
|
6
|
+
@import '../components/atoms/app-button/app-button.css';
|
|
7
|
+
@import '../components/atoms/app-badge/app-badge.css';
|
|
8
|
+
@import '../components/atoms/app-icon/app-icon.css';
|
|
9
|
+
@import '../components/atoms/theme-toggle/theme-toggle.css';
|
|
10
|
+
@import '../components/atoms/canvas-toolbar/canvas-toolbar.css';
|
|
11
|
+
|
|
12
|
+
/* Molecules */
|
|
13
|
+
@import '../components/molecules/task-card/task-card.css';
|
|
14
|
+
@import '../components/molecules/project-card/project-card.css';
|
|
15
|
+
@import '../components/molecules/form-field/form-field.css';
|
|
16
|
+
|
|
17
|
+
/* Organisms */
|
|
18
|
+
@import '../components/organisms/task-list/task-list.css';
|
|
19
|
+
@import '../components/organisms/project-header/project-header.css';
|
|
20
|
+
@import '../components/organisms/schema-form/schema-form.css';
|
|
21
|
+
@import '../components/organisms/project-canvas/project-canvas.css';
|
|
22
|
+
|
|
23
|
+
/* Templates */
|
|
24
|
+
@import '../components/templates/page-layout/page-layout.css';
|
|
25
|
+
|
|
26
|
+
/* Pages */
|
|
27
|
+
@import '../pages/home/home-view.css';
|
|
28
|
+
@import '../pages/project/project-view.css';
|
|
29
|
+
|
|
30
|
+
/* Router */
|
|
31
|
+
@import '../router/router.css';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/* Shared form input styles.
|
|
2
|
+
Loaded globally in index.html. */
|
|
3
|
+
|
|
4
|
+
input,
|
|
5
|
+
select,
|
|
6
|
+
textarea {
|
|
7
|
+
font: inherit;
|
|
8
|
+
padding: var(--space-sm);
|
|
9
|
+
border: 1px solid var(--color-border);
|
|
10
|
+
border-radius: var(--radius-md);
|
|
11
|
+
background: var(--color-surface);
|
|
12
|
+
color: var(--color-text);
|
|
13
|
+
transition: border-color 0.15s ease;
|
|
14
|
+
|
|
15
|
+
&:focus {
|
|
16
|
+
outline: none;
|
|
17
|
+
border-color: var(--color-primary);
|
|
18
|
+
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
*,
|
|
2
|
+
*::before,
|
|
3
|
+
*::after {
|
|
4
|
+
box-sizing: border-box;
|
|
5
|
+
margin: 0;
|
|
6
|
+
padding: 0;
|
|
7
|
+
}
|
|
8
|
+
html {
|
|
9
|
+
font-family: var(--font-sans);
|
|
10
|
+
line-height: 1.5;
|
|
11
|
+
color: var(--color-text);
|
|
12
|
+
background: var(--color-bg);
|
|
13
|
+
}
|
|
14
|
+
body {
|
|
15
|
+
min-height: 100vh;
|
|
16
|
+
}
|
|
17
|
+
a {
|
|
18
|
+
color: var(--color-primary);
|
|
19
|
+
text-decoration: none;
|
|
20
|
+
}
|
|
21
|
+
a:hover {
|
|
22
|
+
text-decoration: underline;
|
|
23
|
+
}
|
|
24
|
+
img,
|
|
25
|
+
svg {
|
|
26
|
+
display: block;
|
|
27
|
+
max-width: 100%;
|
|
28
|
+
}
|
|
29
|
+
button {
|
|
30
|
+
font: inherit;
|
|
31
|
+
cursor: pointer;
|
|
32
|
+
}
|