@mosetta/ui 0.0.0-stage → 0.0.10
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/CHANGELOG.md +66 -0
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/css/base.css +109 -0
- package/css/button.css +264 -0
- package/css/card.css +174 -0
- package/css/form.css +107 -0
- package/css/inline-field.css +195 -0
- package/css/inline.css +46 -0
- package/css/input.css +163 -0
- package/css/stack.css +39 -0
- package/css/styler.css +134 -0
- package/css/typography.css +136 -0
- package/package.json +35 -3
- package/scripts/buildCss.mjs +73 -0
- package/src/cn.ts +28 -0
- package/src/components/button.tsx +46 -0
- package/src/components/card.tsx +23 -0
- package/src/components/form.tsx +31 -0
- package/src/components/inline.tsx +30 -0
- package/src/components/inlineField.tsx +118 -0
- package/src/components/input.tsx +18 -0
- package/src/components/mosaic.tsx +52 -0
- package/src/components/stack.tsx +42 -0
- package/src/components/styler.tsx +229 -0
- package/src/components/typography.tsx +98 -0
- package/src/dockArrangement.ts +212 -0
- package/src/dockLayout.ts +139 -0
- package/src/inline.ts +103 -0
- package/src/ui.ts +54 -0
- package/src/variants.ts +28 -0
- package/styles/theme.css +107 -0
- package/styles/tw.css +36 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/* GENERATED by scripts/buildCss.mjs — do not edit. Source: src/components/typography.tsx */
|
|
2
|
+
|
|
3
|
+
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
|
|
4
|
+
@layer properties;
|
|
5
|
+
.dui-root {
|
|
6
|
+
--dui-spacing: 0.25rem;
|
|
7
|
+
--dui-text-sm: 0.875rem;
|
|
8
|
+
--dui-text-sm--line-height: calc(1.25 / 0.875);
|
|
9
|
+
--dui-text-base: 1rem;
|
|
10
|
+
--dui-text-base--line-height: calc(1.5 / 1);
|
|
11
|
+
--dui-text-lg: 1.125rem;
|
|
12
|
+
--dui-text-lg--line-height: calc(1.75 / 1.125);
|
|
13
|
+
--dui-text-xl: 1.25rem;
|
|
14
|
+
--dui-text-xl--line-height: calc(1.75 / 1.25);
|
|
15
|
+
--dui-text-2xl: 1.5rem;
|
|
16
|
+
--dui-text-2xl--line-height: calc(2 / 1.5);
|
|
17
|
+
--dui-text-3xl: 1.875rem;
|
|
18
|
+
--dui-text-3xl--line-height: calc(2.25 / 1.875);
|
|
19
|
+
--dui-text-4xl: 2.25rem;
|
|
20
|
+
--dui-text-4xl--line-height: calc(2.5 / 2.25);
|
|
21
|
+
--dui-font-weight-semibold: 600;
|
|
22
|
+
--dui-font-weight-extrabold: 800;
|
|
23
|
+
--dui-tracking-tight: -0.025em;
|
|
24
|
+
}
|
|
25
|
+
.dui\:list-decimal {
|
|
26
|
+
list-style-type: decimal;
|
|
27
|
+
}
|
|
28
|
+
.dui\:list-disc {
|
|
29
|
+
list-style-type: disc;
|
|
30
|
+
}
|
|
31
|
+
:where(.dui\:space-y-1 > :not(:last-child)) {
|
|
32
|
+
--tw-space-y-reverse: 0;
|
|
33
|
+
margin-block-start: calc(var(--dui-spacing) * var(--tw-space-y-reverse));
|
|
34
|
+
margin-block-end: calc(var(--dui-spacing) * calc(1 - var(--tw-space-y-reverse)));
|
|
35
|
+
}
|
|
36
|
+
:where(.dui\:space-y-2 > :not(:last-child)) {
|
|
37
|
+
--tw-space-y-reverse: 0;
|
|
38
|
+
margin-block-start: calc(calc(var(--dui-spacing) * 2) * var(--tw-space-y-reverse));
|
|
39
|
+
margin-block-end: calc(calc(var(--dui-spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
|
|
40
|
+
}
|
|
41
|
+
.dui\:border-l-4 {
|
|
42
|
+
border-left-style: var(--tw-border-style);
|
|
43
|
+
border-left-width: 4px;
|
|
44
|
+
}
|
|
45
|
+
.dui\:border-border {
|
|
46
|
+
border-color: var(--dui-border);
|
|
47
|
+
}
|
|
48
|
+
.dui\:pl-4 {
|
|
49
|
+
padding-left: calc(var(--dui-spacing) * 4);
|
|
50
|
+
}
|
|
51
|
+
.dui\:pl-6 {
|
|
52
|
+
padding-left: calc(var(--dui-spacing) * 6);
|
|
53
|
+
}
|
|
54
|
+
.dui\:text-2xl {
|
|
55
|
+
font-size: var(--dui-text-2xl);
|
|
56
|
+
line-height: var(--tw-leading, var(--dui-text-2xl--line-height));
|
|
57
|
+
}
|
|
58
|
+
.dui\:text-3xl {
|
|
59
|
+
font-size: var(--dui-text-3xl);
|
|
60
|
+
line-height: var(--tw-leading, var(--dui-text-3xl--line-height));
|
|
61
|
+
}
|
|
62
|
+
.dui\:text-4xl {
|
|
63
|
+
font-size: var(--dui-text-4xl);
|
|
64
|
+
line-height: var(--tw-leading, var(--dui-text-4xl--line-height));
|
|
65
|
+
}
|
|
66
|
+
.dui\:text-base {
|
|
67
|
+
font-size: var(--dui-text-base);
|
|
68
|
+
line-height: var(--tw-leading, var(--dui-text-base--line-height));
|
|
69
|
+
}
|
|
70
|
+
.dui\:text-lg {
|
|
71
|
+
font-size: var(--dui-text-lg);
|
|
72
|
+
line-height: var(--tw-leading, var(--dui-text-lg--line-height));
|
|
73
|
+
}
|
|
74
|
+
.dui\:text-sm {
|
|
75
|
+
font-size: var(--dui-text-sm);
|
|
76
|
+
line-height: var(--tw-leading, var(--dui-text-sm--line-height));
|
|
77
|
+
}
|
|
78
|
+
.dui\:text-xl {
|
|
79
|
+
font-size: var(--dui-text-xl);
|
|
80
|
+
line-height: var(--tw-leading, var(--dui-text-xl--line-height));
|
|
81
|
+
}
|
|
82
|
+
.dui\:font-extrabold {
|
|
83
|
+
--tw-font-weight: var(--dui-font-weight-extrabold);
|
|
84
|
+
font-weight: var(--dui-font-weight-extrabold);
|
|
85
|
+
}
|
|
86
|
+
.dui\:font-semibold {
|
|
87
|
+
--tw-font-weight: var(--dui-font-weight-semibold);
|
|
88
|
+
font-weight: var(--dui-font-weight-semibold);
|
|
89
|
+
}
|
|
90
|
+
.dui\:tracking-tight {
|
|
91
|
+
--tw-tracking: var(--dui-tracking-tight);
|
|
92
|
+
letter-spacing: var(--dui-tracking-tight);
|
|
93
|
+
}
|
|
94
|
+
.dui\:text-destructive {
|
|
95
|
+
color: var(--dui-destructive);
|
|
96
|
+
}
|
|
97
|
+
.dui\:text-foreground {
|
|
98
|
+
color: var(--dui-foreground);
|
|
99
|
+
}
|
|
100
|
+
.dui\:text-muted-foreground {
|
|
101
|
+
color: var(--dui-muted-foreground);
|
|
102
|
+
}
|
|
103
|
+
.dui\:italic {
|
|
104
|
+
font-style: italic;
|
|
105
|
+
}
|
|
106
|
+
.dui\:not-italic {
|
|
107
|
+
font-style: normal;
|
|
108
|
+
}
|
|
109
|
+
@property --tw-space-y-reverse {
|
|
110
|
+
syntax: "*";
|
|
111
|
+
inherits: false;
|
|
112
|
+
initial-value: 0;
|
|
113
|
+
}
|
|
114
|
+
@property --tw-border-style {
|
|
115
|
+
syntax: "*";
|
|
116
|
+
inherits: false;
|
|
117
|
+
initial-value: solid;
|
|
118
|
+
}
|
|
119
|
+
@property --tw-font-weight {
|
|
120
|
+
syntax: "*";
|
|
121
|
+
inherits: false;
|
|
122
|
+
}
|
|
123
|
+
@property --tw-tracking {
|
|
124
|
+
syntax: "*";
|
|
125
|
+
inherits: false;
|
|
126
|
+
}
|
|
127
|
+
@layer properties {
|
|
128
|
+
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
|
|
129
|
+
*, ::before, ::after, ::backdrop {
|
|
130
|
+
--tw-space-y-reverse: 0;
|
|
131
|
+
--tw-border-style: solid;
|
|
132
|
+
--tw-font-weight: initial;
|
|
133
|
+
--tw-tracking: initial;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mosetta/ui",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.10",
|
|
4
|
+
"description": "mosetta UI base library: shadcn-style primitives with prefix-scoped tailwind",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/ui.ts",
|
|
9
|
+
"./base.css": "./css/base.css",
|
|
10
|
+
"./button.css": "./css/button.css",
|
|
11
|
+
"./card.css": "./css/card.css",
|
|
12
|
+
"./form.css": "./css/form.css",
|
|
13
|
+
"./input.css": "./css/input.css",
|
|
14
|
+
"./stack.css": "./css/stack.css",
|
|
15
|
+
"./typography.css": "./css/typography.css",
|
|
16
|
+
"./inline.css": "./css/inline.css",
|
|
17
|
+
"./inline-field.css": "./css/inline-field.css",
|
|
18
|
+
"./styler.css": "./css/styler.css"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"react": "*",
|
|
22
|
+
"@mosetta/di": "0.0.10",
|
|
23
|
+
"@mosetta/core": "0.0.10"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@tailwindcss/cli": "^4.3.3",
|
|
27
|
+
"@types/react": "*",
|
|
28
|
+
"tailwindcss": "^4",
|
|
29
|
+
"typescript": "*"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"css": "node scripts/buildCss.mjs",
|
|
36
|
+
"typecheck": "tsc --noEmit"
|
|
37
|
+
}
|
|
6
38
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Offline css build: tailwind runs HERE via the local bin (@tailwindcss/cli in devDeps; npx is
|
|
2
|
+
// banned in this repo — it wipes symlinks), never in the CLI module pipeline. Output — css/:
|
|
3
|
+
// base.css theme + scoped mini-preflight (styles/theme.css) — shared by nature
|
|
4
|
+
// <slice>.css per-component slice: `dui:` utilities used ONLY in that component's source
|
|
5
|
+
// (shared utilities are duplicated across slices deliberately)
|
|
6
|
+
// Residual global roots (:root/:host) are rewritten under .dui-root. The files are COMMITTED
|
|
7
|
+
// (consumer builds do not generate them). Regenerate after editing dui:* classes in src/:
|
|
8
|
+
// pnpm --dir packages/ui run css
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const pkg = path.resolve(here, '..');
|
|
16
|
+
const outDir = path.join(pkg, 'css');
|
|
17
|
+
const bin = path.join(pkg, 'node_modules', '.bin', 'tailwindcss');
|
|
18
|
+
const template = fs.readFileSync(path.join(pkg, 'styles', 'tw.css'), 'utf8');
|
|
19
|
+
|
|
20
|
+
const SLICES = [
|
|
21
|
+
['button', 'src/components/button.tsx'],
|
|
22
|
+
['input', 'src/components/input.tsx'],
|
|
23
|
+
['card', 'src/components/card.tsx'],
|
|
24
|
+
['stack', 'src/components/stack.tsx'],
|
|
25
|
+
['typography', 'src/components/typography.tsx'],
|
|
26
|
+
['form', 'src/components/form.tsx'],
|
|
27
|
+
['inline', 'src/components/inline.tsx'],
|
|
28
|
+
['inline-field', 'src/components/inlineField.tsx'],
|
|
29
|
+
['styler', 'src/components/styler.tsx'],
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const banner = (source) => `/* GENERATED by scripts/buildCss.mjs — do not edit. Source: ${source} */\n`;
|
|
33
|
+
|
|
34
|
+
// isolation: no output file may carry a global selector
|
|
35
|
+
function guard(name, css) {
|
|
36
|
+
const globals = [...css.matchAll(/(^|[}\n])\s*(html|body|:root|:host)[\s{,]/g)];
|
|
37
|
+
if (globals.length) {
|
|
38
|
+
console.error(`${name}: ${globals.length} global selector(s) — isolation broken`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
44
|
+
|
|
45
|
+
// base: theme + mini-preflight — already scoped by the author (.dui-root)
|
|
46
|
+
const theme = fs.readFileSync(path.join(pkg, 'styles', 'theme.css'), 'utf8');
|
|
47
|
+
const base = `${banner('styles/theme.css')}\n${theme}`;
|
|
48
|
+
guard('base.css', base);
|
|
49
|
+
fs.writeFileSync(path.join(outDir, 'base.css'), base);
|
|
50
|
+
console.log(`css/base.css written (${base.length} bytes)`);
|
|
51
|
+
|
|
52
|
+
for (const [name, srcRel] of SLICES) {
|
|
53
|
+
const entryPath = path.join(pkg, 'styles', `.tw-entry-${name}.css`);
|
|
54
|
+
const twOut = path.join(pkg, `.tw-out-${name}.css`);
|
|
55
|
+
fs.writeFileSync(entryPath, template.replace("@source '../src';", `@source '../${srcRel}';`));
|
|
56
|
+
const run = spawnSync(bin, ['-i', entryPath, '-o', twOut], { cwd: pkg, stdio: ['ignore', 'inherit', 'inherit'] });
|
|
57
|
+
fs.rmSync(entryPath, { force: true });
|
|
58
|
+
if (run.status !== 0) {
|
|
59
|
+
console.error(`tailwind css generation failed for ${name}`);
|
|
60
|
+
process.exit(run.status ?? 1);
|
|
61
|
+
}
|
|
62
|
+
let utilities = fs.readFileSync(twOut, 'utf8');
|
|
63
|
+
fs.rmSync(twOut, { force: true });
|
|
64
|
+
// tailwind emits some var/property blocks on global roots — rescope them (isolation)
|
|
65
|
+
utilities = utilities
|
|
66
|
+
.replace(/:root\s*,\s*:host/g, '.dui-root')
|
|
67
|
+
.replace(/(^|[}\s]):root(?![\w-])/g, '$1.dui-root')
|
|
68
|
+
.replace(/(^|[}\s]):host(?![\w-])/g, '$1.dui-root');
|
|
69
|
+
const css = `${banner(srcRel)}\n${utilities}`;
|
|
70
|
+
guard(`${name}.css`, css);
|
|
71
|
+
fs.writeFileSync(path.join(outDir, `${name}.css`), css);
|
|
72
|
+
console.log(`css/${name}.css written (${css.length} bytes)`);
|
|
73
|
+
}
|
package/src/cn.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Tiny clsx-style class joiner — ZERO runtime deps by design (the library bundles into
|
|
2
|
+
// every consuming module as a dependency artifact; each dep is weight on every page).
|
|
3
|
+
// NO tailwind-merge conflict resolution: the variants() system composes non-overlapping class sets,
|
|
4
|
+
// and callers override via ORDER (later wins only when properties differ — don't fight specificity).
|
|
5
|
+
export type ClassValue
|
|
6
|
+
= | string
|
|
7
|
+
| number
|
|
8
|
+
| null
|
|
9
|
+
| false
|
|
10
|
+
| undefined
|
|
11
|
+
| ClassValue[]
|
|
12
|
+
| Record<string, boolean | null | undefined>;
|
|
13
|
+
|
|
14
|
+
export function cn(...inputs: ClassValue[]): string {
|
|
15
|
+
const out: string[] = [];
|
|
16
|
+
for (const input of inputs) {
|
|
17
|
+
if (!input && input !== 0) continue;
|
|
18
|
+
if (typeof input === 'string' || typeof input === 'number') {
|
|
19
|
+
out.push(String(input));
|
|
20
|
+
} else if (Array.isArray(input)) {
|
|
21
|
+
const nested = cn(...input);
|
|
22
|
+
if (nested) out.push(nested);
|
|
23
|
+
} else if (typeof input === 'object') {
|
|
24
|
+
for (const [key, on] of Object.entries(input)) if (on) out.push(key);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return out.join(' ');
|
|
28
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// shadcn button, adapted: `dui:` prefix (scoped tailwind), no asChild/Slot
|
|
2
|
+
// (radix arrives with a later batch — the dep ritual is not worth one prop now).
|
|
3
|
+
import { forwardRef, type ButtonHTMLAttributes } from 'react';
|
|
4
|
+
import { variants, type VariantProps } from '../variants';
|
|
5
|
+
|
|
6
|
+
export const buttonVariants = variants({
|
|
7
|
+
base:
|
|
8
|
+
'dui:inline-flex dui:items-center dui:justify-center dui:gap-2 dui:whitespace-nowrap '
|
|
9
|
+
+ 'dui:rounded-md dui:text-sm dui:font-medium dui:transition-colors dui:outline-none '
|
|
10
|
+
+ 'dui:focus-visible:ring-2 dui:focus-visible:ring-ring '
|
|
11
|
+
+ 'dui:disabled:pointer-events-none dui:disabled:opacity-50 dui:shrink-0 dui:cursor-pointer '
|
|
12
|
+
// a button is often an anchor (a block's button leads somewhere): no browser underline on it
|
|
13
|
+
+ 'dui:no-underline',
|
|
14
|
+
variants: {
|
|
15
|
+
variant: {
|
|
16
|
+
default: 'dui:bg-primary dui:text-primary-foreground dui:hover:bg-primary/90',
|
|
17
|
+
secondary: 'dui:bg-secondary dui:text-secondary-foreground dui:hover:bg-secondary/80',
|
|
18
|
+
destructive: 'dui:bg-destructive dui:text-destructive-foreground dui:hover:bg-destructive/90',
|
|
19
|
+
outline: 'dui:border dui:border-input dui:bg-background dui:hover:bg-accent dui:hover:text-accent-foreground',
|
|
20
|
+
ghost: 'dui:hover:bg-accent dui:hover:text-accent-foreground',
|
|
21
|
+
link: 'dui:text-primary dui:underline-offset-4 dui:hover:underline',
|
|
22
|
+
},
|
|
23
|
+
size: {
|
|
24
|
+
default: 'dui:h-9 dui:px-4 dui:py-2',
|
|
25
|
+
sm: 'dui:h-8 dui:rounded-md dui:px-3 dui:text-xs',
|
|
26
|
+
lg: 'dui:h-10 dui:rounded-md dui:px-8',
|
|
27
|
+
icon: 'dui:h-9 dui:w-9',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
defaultVariants: { variant: 'default', size: 'default' },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export interface IButtonProps
|
|
34
|
+
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
|
35
|
+
VariantProps<{ variant: Record<'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link', string>;
|
|
36
|
+
size: Record<'default' | 'sm' | 'lg' | 'icon', string> }> {}
|
|
37
|
+
|
|
38
|
+
export const Button = forwardRef<HTMLButtonElement, IButtonProps>(
|
|
39
|
+
({ className, variant, size, type, ...props }, ref) => (
|
|
40
|
+
<button ref={ref}
|
|
41
|
+
type={type ?? 'button'}
|
|
42
|
+
className={buttonVariants({ variant, size, className })}
|
|
43
|
+
{...props} />
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
Button.displayName = 'Button';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// shadcn card family, `dui:`-prefixed (scoped tailwind).
|
|
2
|
+
import { forwardRef, type HTMLAttributes } from 'react';
|
|
3
|
+
import { cn } from '../cn';
|
|
4
|
+
|
|
5
|
+
type DivProps = HTMLAttributes<HTMLDivElement>;
|
|
6
|
+
|
|
7
|
+
const part = (name: string, classes: string) => {
|
|
8
|
+
const Part = forwardRef<HTMLDivElement, DivProps>(({ className, ...props }, ref) => (
|
|
9
|
+
<div ref={ref}
|
|
10
|
+
className={cn(classes, className)}
|
|
11
|
+
{...props} />
|
|
12
|
+
));
|
|
13
|
+
Part.displayName = name;
|
|
14
|
+
return Part;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const Card = part('Card',
|
|
18
|
+
'dui:rounded-xl dui:border dui:border-border dui:bg-card dui:text-card-foreground dui:shadow-sm');
|
|
19
|
+
export const CardHeader = part('CardHeader', 'dui:flex dui:flex-col dui:gap-1.5 dui:p-6');
|
|
20
|
+
export const CardTitle = part('CardTitle', 'dui:font-semibold dui:leading-none dui:tracking-tight');
|
|
21
|
+
export const CardDescription = part('CardDescription', 'dui:text-sm dui:text-muted-foreground');
|
|
22
|
+
export const CardContent = part('CardContent', 'dui:p-6 dui:pt-0');
|
|
23
|
+
export const CardFooter = part('CardFooter', 'dui:flex dui:items-center dui:p-6 dui:pt-0');
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The editor-form look dictionary: the SINGLE source of dui:* classes for editor facets —
|
|
2
|
+
// the tailwind scan for '@mosetta/ui/form.css' runs over THIS file. A new class in the
|
|
3
|
+
// editor goes here first, otherwise the utility misses the slice and the look silently breaks
|
|
4
|
+
// (the invariant is held by uiKitE2E: every dui:* class in ui-kit sources must be covered by css).
|
|
5
|
+
// Leftover: migrate editor facets from hand-written class strings to these presets.
|
|
6
|
+
import type { HTMLAttributes } from 'react';
|
|
7
|
+
import { cn } from '../cn';
|
|
8
|
+
|
|
9
|
+
export const editorForm = {
|
|
10
|
+
/** a horizontal form row: label + control */
|
|
11
|
+
row: 'dui:flex dui:items-center dui:gap-2',
|
|
12
|
+
/** a text field/select in a settings form */
|
|
13
|
+
field: 'dui:flex dui:h-9 dui:w-full dui:rounded-md dui:border dui:border-input dui:bg-transparent dui:px-3 dui:text-sm',
|
|
14
|
+
/** a select in a settings form: the field, with room on the right for the chevron */
|
|
15
|
+
select: 'dui:flex dui:h-9 dui:w-full dui:rounded-md dui:border dui:border-input dui:bg-transparent dui:pl-3 dui:pr-9 dui:text-sm',
|
|
16
|
+
/** a small secondary button in a settings form (upload, pick, …) */
|
|
17
|
+
button: 'dui:inline-flex dui:h-8 dui:items-center dui:rounded-md dui:border dui:border-input dui:px-3 dui:text-sm dui:hover:bg-accent',
|
|
18
|
+
/** a control that exists for the script, not the eye (a hidden file input) */
|
|
19
|
+
hidden: 'dui:hidden',
|
|
20
|
+
/**
|
|
21
|
+
* a field's label: small and grey, so it reads apart from the values the author types — and
|
|
22
|
+
* set off from the field above it, which is what spaces a form's fields out
|
|
23
|
+
*/
|
|
24
|
+
label: 'dui:block dui:mt-3 dui:mb-1 dui:first:mt-0 dui:text-xs dui:font-medium dui:text-muted-foreground',
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
/** a field's label in a settings form (editorForm.label) */
|
|
28
|
+
export function FormLabel({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
29
|
+
return <div className={cn(editorForm.label, className)}
|
|
30
|
+
{...props} />;
|
|
31
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The react half of the inline grammar: parsed nodes → elements. Bold, italic and code are
|
|
2
|
+
// plain tags; a link is honoured only when its target is safe, otherwise its text stays text.
|
|
3
|
+
import { Fragment, type ReactNode } from 'react';
|
|
4
|
+
import { isSafeHref, parseInline, type TInlineNode } from '../inline';
|
|
5
|
+
|
|
6
|
+
function renderNodes(nodes: readonly TInlineNode[], keyBase: string): ReactNode[] {
|
|
7
|
+
return nodes.map((node, index) => {
|
|
8
|
+
const key = `${keyBase}${index}`;
|
|
9
|
+
switch (node.kind) {
|
|
10
|
+
case 'text': return <Fragment key={key}>{node.text}</Fragment>;
|
|
11
|
+
case 'code': return <code key={key}
|
|
12
|
+
className="dui:rounded dui:bg-muted dui:px-1 dui:font-mono dui:text-[0.9em]">{node.text}</code>;
|
|
13
|
+
case 'bold': return <strong key={key}
|
|
14
|
+
className="dui:font-semibold">{renderNodes(node.children, `${key}.`)}</strong>;
|
|
15
|
+
case 'italic': return <em key={key}>{renderNodes(node.children, `${key}.`)}</em>;
|
|
16
|
+
case 'link':
|
|
17
|
+
return isSafeHref(node.href)
|
|
18
|
+
? <a key={key}
|
|
19
|
+
className="dui-inline-link dui:underline dui:underline-offset-2"
|
|
20
|
+
href={node.href}>{renderNodes(node.children, `${key}.`)}</a>
|
|
21
|
+
: <Fragment key={key}>{renderNodes(node.children, `${key}.`)}</Fragment>;
|
|
22
|
+
default: return null;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** a string setting with inline markup, rendered — what Text, Heading and list items show */
|
|
28
|
+
export function Inline({ text }: { readonly text: string }) {
|
|
29
|
+
return <>{renderNodes(parseInline(text), '')}</>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// The settings-form field for a string with inline markup: a growing textarea and three buttons
|
|
2
|
+
// that wrap the selection in the grammar's marks — **bold**, *italic*, [text](url). No
|
|
3
|
+
// contentEditable, no preview inside the field: the canvas beside the form IS the preview. The
|
|
4
|
+
// field asks for focus on mount when told to, so picking a block in the tree lands the cursor
|
|
5
|
+
// where the writing happens. It is at least `minRows` tall and grows with its text, and it is a
|
|
6
|
+
// WIDE field (`data-wide-field`): a host with room — the canvas's settings card — widens for it.
|
|
7
|
+
import { useEffect, useRef, type TextareaHTMLAttributes } from 'react';
|
|
8
|
+
import { cn } from '../cn';
|
|
9
|
+
|
|
10
|
+
export interface IInlineFieldProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange'> {
|
|
11
|
+
readonly value: string;
|
|
12
|
+
readonly onValueChange: (next: string) => void;
|
|
13
|
+
/** take the focus on mount (the tree just picked this block) */
|
|
14
|
+
readonly focusOnMount?: boolean;
|
|
15
|
+
/** how a link's target is asked for; default: window.prompt */
|
|
16
|
+
readonly askHref?: () => string | null;
|
|
17
|
+
/** the least height, in rows of text — writing needs room to see a paragraph (default 5) */
|
|
18
|
+
readonly minRows?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type TMark = { readonly kind: 'bold' | 'italic' | 'link'; readonly label: string; readonly title: string };
|
|
22
|
+
|
|
23
|
+
const MARKS: readonly TMark[] = [
|
|
24
|
+
{ kind: 'bold', label: 'B', title: 'Bold (**text**)' },
|
|
25
|
+
{ kind: 'italic', label: 'I', title: 'Italic (*text*)' },
|
|
26
|
+
{ kind: 'link', label: '⛓', title: 'Link ([text](url))' },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/** the string with the selection wrapped; the caret lands after the closing mark */
|
|
30
|
+
export function wrapSelection(value: string, start: number, end: number, kind: TMark['kind'], href = ''): { next: string; caret: number } {
|
|
31
|
+
const picked = value.slice(start, end);
|
|
32
|
+
const wrapped = kind === 'bold' ? `**${picked || 'bold'}**`
|
|
33
|
+
: kind === 'italic' ? `*${picked || 'italic'}*`
|
|
34
|
+
: `[${picked || 'link'}](${href})`;
|
|
35
|
+
return { next: value.slice(0, start) + wrapped + value.slice(end), caret: start + wrapped.length };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** the textarea as tall as its text: the content height plus its own borders (border-box) */
|
|
39
|
+
function fitHeight(el: HTMLTextAreaElement): void {
|
|
40
|
+
el.style.height = 'auto';
|
|
41
|
+
el.style.height = `${el.scrollHeight + el.offsetHeight - el.clientHeight}px`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const MARK_CLASS = 'dui:inline-flex dui:h-7 dui:min-w-7 dui:items-center dui:justify-center dui:rounded-md dui:border dui:border-input dui:px-2 dui:text-xs dui:font-semibold dui:hover:bg-accent';
|
|
45
|
+
|
|
46
|
+
export function InlineField({ value, onValueChange, focusOnMount, askHref, minRows = 5, className, ...props }: IInlineFieldProps) {
|
|
47
|
+
const ref = useRef<HTMLTextAreaElement>(null);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (focusOnMount) ref.current?.focus();
|
|
50
|
+
}, [focusOnMount]);
|
|
51
|
+
// the textarea follows its content: an article paragraph is longer than one row — and its
|
|
52
|
+
// WIDTH: the same text wraps into more rows when the form narrows (a resized popover), so the
|
|
53
|
+
// height is measured again whenever the width changes, not only when the text does
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (ref.current) fitHeight(ref.current);
|
|
56
|
+
}, [value]);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const el = ref.current;
|
|
59
|
+
const Observer = el?.ownerDocument.defaultView?.ResizeObserver;
|
|
60
|
+
if (!el || !Observer) return undefined;
|
|
61
|
+
let width = el.clientWidth;
|
|
62
|
+
const observer = new Observer(() => {
|
|
63
|
+
if (el.clientWidth === width) return; // our own height write — nothing to re-measure
|
|
64
|
+
width = el.clientWidth;
|
|
65
|
+
fitHeight(el);
|
|
66
|
+
});
|
|
67
|
+
observer.observe(el);
|
|
68
|
+
return () => observer.disconnect();
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
const apply = (kind: TMark['kind']) => {
|
|
72
|
+
const el = ref.current;
|
|
73
|
+
if (!el) return;
|
|
74
|
+
let href = '';
|
|
75
|
+
if (kind === 'link') {
|
|
76
|
+
const asked = (askHref ?? ((): string | null => (typeof window !== 'undefined' ? window.prompt('Link address') : null)))();
|
|
77
|
+
if (asked === null) return;
|
|
78
|
+
href = asked.trim();
|
|
79
|
+
}
|
|
80
|
+
const { next, caret } = wrapSelection(value, el.selectionStart, el.selectionEnd, kind, href);
|
|
81
|
+
onValueChange(next);
|
|
82
|
+
requestAnimationFrame(() => {
|
|
83
|
+
el.focus();
|
|
84
|
+
el.setSelectionRange(caret, caret);
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="dui:flex dui:flex-col dui:gap-1">
|
|
90
|
+
<div className="dui:flex dui:gap-1"
|
|
91
|
+
data-inline-toolbar>
|
|
92
|
+
{MARKS.map((mark) => (
|
|
93
|
+
<button key={mark.kind}
|
|
94
|
+
type="button"
|
|
95
|
+
className={MARK_CLASS}
|
|
96
|
+
title={mark.title}
|
|
97
|
+
aria-label={mark.title}
|
|
98
|
+
data-inline-mark={mark.kind}
|
|
99
|
+
onMouseDown={(e) => e.preventDefault()} // keep the textarea's selection alive
|
|
100
|
+
onClick={() => apply(mark.kind)}>
|
|
101
|
+
{mark.label}
|
|
102
|
+
</button>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
<textarea ref={ref}
|
|
106
|
+
className={cn(
|
|
107
|
+
'dui:flex dui:min-h-9 dui:w-full dui:resize-none dui:rounded-md dui:border dui:border-input dui:bg-transparent',
|
|
108
|
+
'dui:px-3 dui:py-1.5 dui:text-sm dui:shadow-sm dui:outline-none dui:focus-visible:ring-2 dui:focus-visible:ring-ring',
|
|
109
|
+
className,
|
|
110
|
+
)}
|
|
111
|
+
rows={minRows}
|
|
112
|
+
data-wide-field=""
|
|
113
|
+
value={value ?? ''}
|
|
114
|
+
onChange={(e) => onValueChange(e.target.value)}
|
|
115
|
+
{...props} />
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// shadcn input, `dui:`-prefixed (scoped tailwind).
|
|
2
|
+
import { forwardRef, type InputHTMLAttributes } from 'react';
|
|
3
|
+
import { cn } from '../cn';
|
|
4
|
+
|
|
5
|
+
export interface IInputProps extends InputHTMLAttributes<HTMLInputElement> {}
|
|
6
|
+
|
|
7
|
+
export const Input = forwardRef<HTMLInputElement, IInputProps>(({ className, ...props }, ref) => (
|
|
8
|
+
<input ref={ref}
|
|
9
|
+
className={cn(
|
|
10
|
+
'dui:flex dui:h-9 dui:w-full dui:rounded-md dui:border dui:border-input dui:bg-transparent',
|
|
11
|
+
'dui:px-3 dui:py-1 dui:text-sm dui:shadow-sm dui:transition-colors dui:outline-none',
|
|
12
|
+
'dui:placeholder:text-muted-foreground dui:focus-visible:ring-2 dui:focus-visible:ring-ring',
|
|
13
|
+
'dui:disabled:cursor-not-allowed dui:disabled:opacity-50',
|
|
14
|
+
className,
|
|
15
|
+
)}
|
|
16
|
+
{...props} />
|
|
17
|
+
));
|
|
18
|
+
Input.displayName = 'Input';
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// A MOSAIC — a 4×4 pattern of tiles drawn from a name. mosetta builds pages from blocks the way
|
|
2
|
+
// a mosaic is laid from tiles; the pattern makes a site (or a person) recognisable at a glance
|
|
3
|
+
// and never changes while the name does not. The cabinet wears it on every site, the workbench
|
|
4
|
+
// on the project and the account.
|
|
5
|
+
import { ComponentProps } from 'react';
|
|
6
|
+
|
|
7
|
+
const TILES = ['#2f5bea', '#11a69a', '#f2b635', '#f0685a', '#7a4cc2'];
|
|
8
|
+
|
|
9
|
+
function digest(text: string): number {
|
|
10
|
+
let hash = 0x811c9dc5;
|
|
11
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
12
|
+
hash ^= text.charCodeAt(i);
|
|
13
|
+
hash = Math.imul(hash, 0x01000193);
|
|
14
|
+
}
|
|
15
|
+
return hash >>> 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface IMosaicProps extends Omit<ComponentProps<'svg'>, 'width' | 'height' | 'viewBox'> {
|
|
19
|
+
/** the name the tiles are drawn from */
|
|
20
|
+
readonly seed: string;
|
|
21
|
+
/** the square's side, in pixels */
|
|
22
|
+
readonly size: number;
|
|
23
|
+
/** the colour of a bare cell — defaults to a faint grey */
|
|
24
|
+
readonly bare?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** a name's tiles: sixteen cells, some left bare, the rest coloured — the same name, the same mosaic */
|
|
28
|
+
export function Mosaic({ seed, size, bare = 'rgba(127, 127, 127, 0.14)', ...rest }: IMosaicProps) {
|
|
29
|
+
const cell = size / 4;
|
|
30
|
+
const gap = Math.max(1, Math.round(size / 24));
|
|
31
|
+
const cells = Array.from({ length: 16 }, (_, i) => {
|
|
32
|
+
const h = digest(`${seed}#${i}`);
|
|
33
|
+
return h % 5 === 0 ? null : TILES[(h >>> 4) % TILES.length];
|
|
34
|
+
});
|
|
35
|
+
return (
|
|
36
|
+
<svg width={size}
|
|
37
|
+
height={size}
|
|
38
|
+
viewBox={`0 0 ${size} ${size}`}
|
|
39
|
+
aria-hidden="true"
|
|
40
|
+
{...rest}>
|
|
41
|
+
{cells.map((fill, i) => (
|
|
42
|
+
<rect key={i}
|
|
43
|
+
x={(i % 4) * cell + gap / 2}
|
|
44
|
+
y={Math.floor(i / 4) * cell + gap / 2}
|
|
45
|
+
width={cell - gap}
|
|
46
|
+
height={cell - gap}
|
|
47
|
+
rx={gap}
|
|
48
|
+
fill={fill ?? bare} />
|
|
49
|
+
))}
|
|
50
|
+
</svg>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Layout stack — lives in the LIBRARY so its dui:* classes are covered by the css scan
|
|
2
|
+
// (consumers may not introduce utilities). The `stack` protocol module wraps this.
|
|
3
|
+
import { forwardRef, type HTMLAttributes } from 'react';
|
|
4
|
+
import { variants, type VariantProps } from '../variants';
|
|
5
|
+
|
|
6
|
+
export const stackVariants = variants({
|
|
7
|
+
base: 'dui:flex',
|
|
8
|
+
variants: {
|
|
9
|
+
direction: {
|
|
10
|
+
vertical: 'dui:flex-col',
|
|
11
|
+
horizontal: 'dui:flex-row',
|
|
12
|
+
},
|
|
13
|
+
gap: {
|
|
14
|
+
none: 'dui:gap-0',
|
|
15
|
+
sm: 'dui:gap-2',
|
|
16
|
+
md: 'dui:gap-4',
|
|
17
|
+
lg: 'dui:gap-6',
|
|
18
|
+
},
|
|
19
|
+
align: {
|
|
20
|
+
start: 'dui:items-start',
|
|
21
|
+
center: 'dui:items-center',
|
|
22
|
+
end: 'dui:items-end',
|
|
23
|
+
stretch: 'dui:items-stretch',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
defaultVariants: { direction: 'vertical', gap: 'md', align: 'stretch' },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export interface IStackProps
|
|
30
|
+
extends HTMLAttributes<HTMLDivElement>,
|
|
31
|
+
VariantProps<{ direction: Record<'vertical' | 'horizontal', string>;
|
|
32
|
+
gap: Record<'none' | 'sm' | 'md' | 'lg', string>;
|
|
33
|
+
align: Record<'start' | 'center' | 'end' | 'stretch', string> }> {}
|
|
34
|
+
|
|
35
|
+
export const Stack = forwardRef<HTMLDivElement, IStackProps>(
|
|
36
|
+
({ className, direction, gap, align, ...props }, ref) => (
|
|
37
|
+
<div ref={ref}
|
|
38
|
+
className={stackVariants({ direction, gap, align, className })}
|
|
39
|
+
{...props} />
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
Stack.displayName = 'Stack';
|