@helical-design/substrate 0.1.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/README.md +23 -0
- package/bin/create-helical.mjs +59 -0
- package/package.json +40 -0
- package/src/build-all.mjs +59 -0
- package/src/build.mjs +31 -0
- package/src/content.mjs +6 -0
- package/src/db/client.mjs +45 -0
- package/src/db/schema.mjs +15 -0
- package/src/db/vector.mjs +10 -0
- package/src/harness.mjs +449 -0
- package/src/index.mjs +19 -0
- package/src/pages.mjs +14 -0
- package/src/palette.mjs +39 -0
- package/src/registry.mjs +29 -0
- package/src/render.mjs +43 -0
- package/src/scaffold.mjs +200 -0
- package/src/scale.mjs +268 -0
- package/src/store.mjs +38 -0
- package/src/tokens.mjs +79 -0
- package/templates/db/_shared/drizzle.config.mjs +9 -0
- package/templates/db/neon/client.mjs +7 -0
- package/templates/db/neon/env.example +6 -0
- package/templates/db/supabase/client.mjs +7 -0
- package/templates/db/supabase/config.toml +11 -0
- package/templates/db/supabase/env.example +8 -0
- package/templates/db/supabase/migrations/0001_init.sql +12 -0
- package/templates/profiles/_base/README.md +22 -0
- package/templates/profiles/_base/assets/base.css +50 -0
- package/templates/profiles/_base/content/about.md +4 -0
- package/templates/profiles/_base/content/site.json +1 -0
- package/templates/profiles/_base/pages.json +4 -0
- package/templates/profiles/_base/templates/layout.html +1 -0
- package/templates/profiles/_base/templates/pages/about.html +2 -0
- package/templates/profiles/_base/templates/pages/home.html +3 -0
- package/templates/profiles/_base/tokens/accent.dark.json +7 -0
- package/templates/profiles/_base/tokens/accent.light.json +7 -0
- package/templates/profiles/_base/tokens/base.json +18 -0
- package/templates/profiles/_base/tokens/breakpoints.json +38 -0
- package/templates/profiles/_base/tokens/palette.dark.json +96 -0
- package/templates/profiles/_base/tokens/palette.light.json +96 -0
- package/templates/profiles/_base/tokens/scale.compact.json +68 -0
- package/templates/profiles/_base/tokens/scale.json +114 -0
- package/templates/profiles/_base/tokens/scale.spacious.json +68 -0
- package/templates/profiles/_base/tokens/semantic.json +14 -0
- package/templates/profiles/app/.keep +0 -0
- package/templates/profiles/marketing/.keep +0 -0
- package/tokens/accent.dark.json +7 -0
- package/tokens/accent.light.json +7 -0
- package/tokens/base.json +18 -0
- package/tokens/breakpoints.json +38 -0
- package/tokens/palette.dark.json +96 -0
- package/tokens/palette.light.json +96 -0
- package/tokens/scale.compact.json +68 -0
- package/tokens/scale.json +114 -0
- package/tokens/scale.spacious.json +68 -0
- package/tokens/semantic.json +14 -0
package/src/scaffold.mjs
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// src/scaffold.mjs — create-helical core. A pure, offline, deterministic
|
|
2
|
+
// project stamper. Layered model: _base skeleton → profile overlay → (app only)
|
|
3
|
+
// a DB overlay assembled from the Spec D templates under templates/db/<provider>.
|
|
4
|
+
//
|
|
5
|
+
// Atomicity: everything is stamped into a staging temp dir first, then moved into
|
|
6
|
+
// the target in one shot. On ANY error the staging dir AND every entry written
|
|
7
|
+
// into the target are removed, so a failed scaffold never leaves a half-project.
|
|
8
|
+
import {
|
|
9
|
+
readFileSync, writeFileSync, mkdtempSync, mkdirSync, rmSync, cpSync,
|
|
10
|
+
readdirSync, existsSync, renameSync, statSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { join, dirname } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
|
|
16
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const PKG_ROOT = join(HERE, '..');
|
|
18
|
+
|
|
19
|
+
const PROFILES = new Set(['marketing', 'app']);
|
|
20
|
+
const DBS = new Set(['neon', 'supabase']);
|
|
21
|
+
// npm package name: lowercase, no spaces/uppercase/illegal chars. Scoped names
|
|
22
|
+
// are allowed; this is the practical kebab-ish subset the scaffolder accepts.
|
|
23
|
+
const NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
|
|
24
|
+
|
|
25
|
+
function readOwnPackage() {
|
|
26
|
+
return JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Pick a dependency's exact declared range from the substrate's own package.json
|
|
30
|
+
// (dependencies or devDependencies), so a stamped app stays in lockstep with us.
|
|
31
|
+
function ownRange(pkg, dep) {
|
|
32
|
+
const range = pkg.dependencies?.[dep] ?? pkg.devDependencies?.[dep];
|
|
33
|
+
if (!range) throw new Error(`scaffold: substrate package.json is missing a range for '${dep}'`);
|
|
34
|
+
return range;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Recursively copy `src` into `dest`, skipping `.keep` overlay markers.
|
|
38
|
+
function copyTree(src, dest) {
|
|
39
|
+
if (!existsSync(src)) throw new Error(`scaffold: template source missing: ${src}`);
|
|
40
|
+
mkdirSync(dest, { recursive: true });
|
|
41
|
+
for (const entry of readdirSync(src)) {
|
|
42
|
+
if (entry === '.keep') continue;
|
|
43
|
+
const s = join(src, entry);
|
|
44
|
+
const d = join(dest, entry);
|
|
45
|
+
if (statSync(s).isDirectory()) copyTree(s, d);
|
|
46
|
+
else cpSync(s, d);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function copyFile(src, dest) {
|
|
51
|
+
if (!existsSync(src)) throw new Error(`scaffold: template source missing: ${src}`);
|
|
52
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
53
|
+
cpSync(src, dest);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function dirIsEmpty(dir) {
|
|
57
|
+
return !existsSync(dir) || readdirSync(dir).length === 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function scaffold({
|
|
61
|
+
name, dir, profile = 'marketing', db, force = false, __templatesRoot,
|
|
62
|
+
} = {}) {
|
|
63
|
+
// --- validation (fail loud, before touching the filesystem) ---------------
|
|
64
|
+
if (!name || !NAME_RE.test(name)) {
|
|
65
|
+
throw new Error(`scaffold: invalid project name: ${JSON.stringify(name)} — use a valid npm package name (lowercase, no spaces)`);
|
|
66
|
+
}
|
|
67
|
+
if (!PROFILES.has(profile)) {
|
|
68
|
+
throw new Error(`scaffold: unknown profile: ${JSON.stringify(profile)} — expected 'marketing' or 'app'`);
|
|
69
|
+
}
|
|
70
|
+
if (db !== undefined) {
|
|
71
|
+
if (profile !== 'app') {
|
|
72
|
+
throw new Error(`scaffold: db is only meaningful for the app profile (got profile '${profile}')`);
|
|
73
|
+
}
|
|
74
|
+
if (!DBS.has(db)) {
|
|
75
|
+
throw new Error(`scaffold: unknown db: ${JSON.stringify(db)} — expected 'neon' or 'supabase'`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const provider = profile === 'app' ? (db ?? 'neon') : null;
|
|
79
|
+
|
|
80
|
+
if (!dir) throw new Error('scaffold: a target dir is required');
|
|
81
|
+
if (!force && !dirIsEmpty(dir)) {
|
|
82
|
+
throw new Error(`scaffold: target dir is not empty: ${dir} — pass force to overwrite`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const templatesRoot = __templatesRoot ?? join(PKG_ROOT, 'templates');
|
|
86
|
+
const ownPkg = readOwnPackage();
|
|
87
|
+
const version = ownPkg.version;
|
|
88
|
+
|
|
89
|
+
// --- stage everything into a temp dir, then move atomically ---------------
|
|
90
|
+
const staging = mkdtempSync(join(tmpdir(), 'helical-stage-'));
|
|
91
|
+
// Track what we move into the target so rollback can undo a partial move.
|
|
92
|
+
const moved = [];
|
|
93
|
+
try {
|
|
94
|
+
stampInto(staging, { name, profile, provider, version, templatesRoot, ownPkg });
|
|
95
|
+
|
|
96
|
+
mkdirSync(dir, { recursive: true });
|
|
97
|
+
for (const entry of readdirSync(staging)) {
|
|
98
|
+
const from = join(staging, entry);
|
|
99
|
+
const to = join(dir, entry);
|
|
100
|
+
rmSync(to, { recursive: true, force: true });
|
|
101
|
+
try {
|
|
102
|
+
renameSync(from, to);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
// Cross-filesystem move (tmp and target on different mounts) — rename
|
|
105
|
+
// can't span devices, so copy-then-remove. Common on CI.
|
|
106
|
+
if (err.code !== 'EXDEV') throw err;
|
|
107
|
+
cpSync(from, to, { recursive: true });
|
|
108
|
+
rmSync(from, { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
moved.push(to); // track the target regardless of which move path was taken
|
|
111
|
+
}
|
|
112
|
+
} catch (err) {
|
|
113
|
+
// Roll back any entries already moved into the target, plus staging.
|
|
114
|
+
for (const p of moved) rmSync(p, { recursive: true, force: true });
|
|
115
|
+
rmSync(staging, { recursive: true, force: true });
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
rmSync(staging, { recursive: true, force: true });
|
|
119
|
+
return dir;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Stamp the full layered project into an (empty) staging dir.
|
|
123
|
+
function stampInto(out, { name, profile, provider, version, templatesRoot, ownPkg }) {
|
|
124
|
+
const profilesRoot = join(templatesRoot, 'profiles');
|
|
125
|
+
const dbRoot = join(templatesRoot, 'db');
|
|
126
|
+
|
|
127
|
+
// 1. _base skeleton (tokens / content / templates / pages.json / .gitignore / README).
|
|
128
|
+
copyTree(join(profilesRoot, '_base'), out);
|
|
129
|
+
|
|
130
|
+
// 2. profile overlay (static profile-specific files, if any).
|
|
131
|
+
copyTree(join(profilesRoot, profile), out);
|
|
132
|
+
|
|
133
|
+
// 3. rendered, version-aware files.
|
|
134
|
+
const pkg = {
|
|
135
|
+
name,
|
|
136
|
+
version: '0.0.0',
|
|
137
|
+
private: true,
|
|
138
|
+
type: 'module',
|
|
139
|
+
scripts: {
|
|
140
|
+
build: "node -e \"import('@helical/substrate').then(m => m.build())\"",
|
|
141
|
+
},
|
|
142
|
+
dependencies: { '@helical/substrate': `^${version}` },
|
|
143
|
+
};
|
|
144
|
+
// app projects import the DB drivers from their OWN node_modules (the stamped
|
|
145
|
+
// db/*.mjs + drizzle.config.mjs load them), so they must declare them — pinned
|
|
146
|
+
// to the exact ranges the substrate declares so the two stay in lockstep.
|
|
147
|
+
if (profile === 'app') {
|
|
148
|
+
pkg.dependencies['drizzle-orm'] = ownRange(ownPkg, 'drizzle-orm');
|
|
149
|
+
if (provider === 'supabase') {
|
|
150
|
+
pkg.dependencies['postgres'] = ownRange(ownPkg, 'postgres');
|
|
151
|
+
} else {
|
|
152
|
+
pkg.dependencies['@neondatabase/serverless'] = ownRange(ownPkg, '@neondatabase/serverless');
|
|
153
|
+
}
|
|
154
|
+
pkg.devDependencies = { 'drizzle-kit': ownRange(ownPkg, 'drizzle-kit') };
|
|
155
|
+
}
|
|
156
|
+
writeFileSync(join(out, 'package.json'), JSON.stringify(pkg, null, 2) + '\n');
|
|
157
|
+
writeFileSync(join(out, 'helical.config.mjs'), renderConfig({ profile, provider, version }));
|
|
158
|
+
|
|
159
|
+
// README name substitution (keeps the skeleton template generic).
|
|
160
|
+
const readmePath = join(out, 'README.md');
|
|
161
|
+
if (existsSync(readmePath)) {
|
|
162
|
+
writeFileSync(readmePath, readFileSync(readmePath, 'utf8').replaceAll('{{name}}', name));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 4. app-only DB overlay, assembled from the Spec D templates + src/db.
|
|
166
|
+
if (profile === 'app') stampDb(out, { provider, dbRoot });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function renderConfig({ profile, provider, version }) {
|
|
170
|
+
const lines = [
|
|
171
|
+
'// helical.config.mjs — generated by create-helical. Pins the substrate',
|
|
172
|
+
'// version this project was scaffolded against.',
|
|
173
|
+
'export default {',
|
|
174
|
+
` profile: '${profile}',`,
|
|
175
|
+
];
|
|
176
|
+
if (provider) lines.push(` db: '${provider}',`);
|
|
177
|
+
lines.push(` substrateVersion: '${version}',`, '};', '');
|
|
178
|
+
return lines.join('\n');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Assemble the app's db/ from the shared Spec D templates — never duplicated here.
|
|
182
|
+
// db/schema.mjs, db/vector.mjs ← src/db/ (the ONE shared schema + query)
|
|
183
|
+
// db/client.mjs ← templates/db/<provider>/client.mjs
|
|
184
|
+
// drizzle.config.mjs ← templates/db/_shared/drizzle.config.mjs
|
|
185
|
+
// .env.example ← templates/db/<provider>/env.example
|
|
186
|
+
// supabase/{config.toml,migrations/} ← templates/db/supabase/ (supabase only)
|
|
187
|
+
function stampDb(out, { provider, dbRoot }) {
|
|
188
|
+
const srcDb = join(PKG_ROOT, 'src', 'db');
|
|
189
|
+
copyFile(join(srcDb, 'schema.mjs'), join(out, 'db', 'schema.mjs'));
|
|
190
|
+
copyFile(join(srcDb, 'vector.mjs'), join(out, 'db', 'vector.mjs'));
|
|
191
|
+
|
|
192
|
+
copyFile(join(dbRoot, provider, 'client.mjs'), join(out, 'db', 'client.mjs'));
|
|
193
|
+
copyFile(join(dbRoot, '_shared', 'drizzle.config.mjs'), join(out, 'drizzle.config.mjs'));
|
|
194
|
+
copyFile(join(dbRoot, provider, 'env.example'), join(out, '.env.example'));
|
|
195
|
+
|
|
196
|
+
if (provider === 'supabase') {
|
|
197
|
+
copyFile(join(dbRoot, 'supabase', 'config.toml'), join(out, 'supabase', 'config.toml'));
|
|
198
|
+
copyTree(join(dbRoot, 'supabase', 'migrations'), join(out, 'supabase', 'migrations'));
|
|
199
|
+
}
|
|
200
|
+
}
|
package/src/scale.mjs
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// src/scale.mjs — Size is as generative as color. One config-driven scale
|
|
2
|
+
// generator turns a profile (plain data) into space / size / radius / leading /
|
|
3
|
+
// tracking ladders + an a11y target, emitted as DTCG JSON for Style Dictionary —
|
|
4
|
+
// the size-side mirror of src/palette.mjs.
|
|
5
|
+
//
|
|
6
|
+
// Algorithm (the genuine core): a constructive monotonic grid scale. Each ladder
|
|
7
|
+
// is built ascending from its smallest step:
|
|
8
|
+
//
|
|
9
|
+
// step[n] = max( step[n-1] + gridUnit, snapToGrid( base · ratio^n ) )
|
|
10
|
+
//
|
|
11
|
+
// The grid floor IS the damping — where geometric steps fall closer than one grid
|
|
12
|
+
// unit (the small end) the +gridUnit term dominates and the bottom goes linear on
|
|
13
|
+
// the grid automatically (space: 4,8,12,16). A duplicate is unrepresentable, so
|
|
14
|
+
// the ladder is strictly increasing by construction. Density (compact ×0.85) is
|
|
15
|
+
// applied INSIDE the build to the geometric ideal, then the same constructive
|
|
16
|
+
// build re-runs — never multiply-the-output-then-resnap (that collides).
|
|
17
|
+
//
|
|
18
|
+
// The grid is 4px-aligned throughout (every space/radius/target value is a
|
|
19
|
+
// multiple of 4 — the "common denominator"); the snap granularity itself grows
|
|
20
|
+
// with magnitude (4 → 8 → 16) so the grid stays the floor at every scale instead
|
|
21
|
+
// of fracturing large steps. Type snaps to whole px (unit stays rem upstream).
|
|
22
|
+
|
|
23
|
+
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
const SPACE_STEPS = 8; // space.1..8
|
|
27
|
+
const COMPACT_MULT = 0.85; // density multiplier (applied inside the build)
|
|
28
|
+
const SPACIOUS_MULT = 1.15; // airier density (v2) — same constructive build, larger ideal
|
|
29
|
+
const TYPE_FLOOR = 12; // a11y: smallest type role never below 12px (every density)
|
|
30
|
+
const COMPACT_BODY_FLOOR = 14; // a11y SHOULD: compact body never below 14px
|
|
31
|
+
const BODY_FLOOR = 16; // comfy/spacious body floor (px) — iOS form-field zoom guard
|
|
32
|
+
// (compact uses the existing COMPACT_BODY_FLOOR = 14 at every endpoint)
|
|
33
|
+
// Leading couples to density (v2): body leading tightens compact / loosens spacious by
|
|
34
|
+
// this much, floored for legibility. Heading (`tight`) leading stays density-stable.
|
|
35
|
+
const LEADING_DENSITY_DELTA = 0.1;
|
|
36
|
+
const LEADING_FLOOR = 1.4;
|
|
37
|
+
|
|
38
|
+
// --- profiles: plain data only (no functions, no per-profile branching) ------
|
|
39
|
+
// `ui` is the built/committed default; `editorial` is the alternate. In v1 they
|
|
40
|
+
// differ ONLY by these data rows — every code path is shared.
|
|
41
|
+
export const PROFILES = {
|
|
42
|
+
ui: {
|
|
43
|
+
name: 'ui',
|
|
44
|
+
typeBase: 16, // body size in px (16px === 1rem)
|
|
45
|
+
typeRatio: 1.5, // perfect fifth → h3/h2/h1 = 24/36/54
|
|
46
|
+
spaceBase: 4, // smallest space step in px
|
|
47
|
+
spaceRatio: 1.5,
|
|
48
|
+
leading: { body: 1.6, tight: 1.2 },
|
|
49
|
+
// Size-coupled tracking curve (em): track(px) = clamp(min, max, intercept + slope·px),
|
|
50
|
+
// applied per heading. ui is flat (slope 0) → −0.01em on every heading, unchanged.
|
|
51
|
+
tracking: { slope: 0, intercept: -0.01, min: -0.01, max: -0.01 },
|
|
52
|
+
viewport: { min: 480, max: 1280 },
|
|
53
|
+
fluidMagnitude: { min: 0.8, max: 1.0 },
|
|
54
|
+
breakpoints: { sm: 480, md: 768, lg: 1024, xl: 1280 },
|
|
55
|
+
container: { sm: 480, md: 720, lg: 960, xl: 1200 },
|
|
56
|
+
},
|
|
57
|
+
editorial: {
|
|
58
|
+
name: 'editorial',
|
|
59
|
+
typeBase: 16,
|
|
60
|
+
typeRatio: 1.618, // golden ratio → larger, more dramatic display headings
|
|
61
|
+
spaceBase: 4,
|
|
62
|
+
spaceRatio: 1.5,
|
|
63
|
+
leading: { body: 1.7, tight: 1.15 },
|
|
64
|
+
// Optical: bigger display type tracks tighter. ~0 near body, clamps at −0.05em for h1.
|
|
65
|
+
tracking: { slope: -0.0018, intercept: 0.0288, min: -0.05, max: 0.01 },
|
|
66
|
+
viewport: { min: 480, max: 1280 },
|
|
67
|
+
fluidMagnitude: { min: 0.8, max: 1.0 },
|
|
68
|
+
breakpoints: { sm: 480, md: 768, lg: 1024, xl: 1280 },
|
|
69
|
+
container: { sm: 480, md: 720, lg: 960, xl: 1200 },
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// --- grid primitives ---------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
// Snap a value to a 4px-aligned grid whose granularity grows with magnitude so
|
|
76
|
+
// the grid remains the construction floor at every scale: 4 below ~16, 8 below
|
|
77
|
+
// ~32, 16 above. Every result is a multiple of 4.
|
|
78
|
+
function snapToGrid(x) {
|
|
79
|
+
const unit = x <= 16 ? 4 : x <= 32 ? 8 : 16;
|
|
80
|
+
return Math.round(x / unit) * unit;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// One constructive ladder. `snap` maps the geometric ideal onto the grid (4px
|
|
84
|
+
// for space, 1px for type); `floor` is the per-step grid increment that damps the
|
|
85
|
+
// small end (4 for space, 1 for type). `mult` is the density multiplier applied
|
|
86
|
+
// to the geometric ideal BEFORE snapping (monotonic-safe by re-running the build).
|
|
87
|
+
function gridScale(base, ratio, count, { snap, floor, mult = 1 }) {
|
|
88
|
+
const out = [];
|
|
89
|
+
for (let n = 0; n < count; n++) {
|
|
90
|
+
const ideal = snap(base * ratio ** n * mult);
|
|
91
|
+
out.push(n === 0 ? Math.max(floor, ideal) : Math.max(out[n - 1] + floor, ideal));
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Constructive descent for the sub-body type roles (small, caption). Built from
|
|
97
|
+
// the bottom up so a 12px floor damps the descent exactly as the grid floor damps
|
|
98
|
+
// the ascent — strictly increasing toward body by construction.
|
|
99
|
+
function descendType(base, ratio, mult) {
|
|
100
|
+
const caption = Math.max(TYPE_FLOOR, Math.round((base / ratio ** 2) * mult));
|
|
101
|
+
const small = Math.max(caption + 1, Math.round((base / ratio) * mult));
|
|
102
|
+
return { caption, small };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const clamp = (lo, hi, x) => Math.min(hi, Math.max(lo, x));
|
|
106
|
+
const round4 = (x) => Math.round(x * 1e4) / 1e4; // em precision, no float tails
|
|
107
|
+
|
|
108
|
+
// --- buildScale: pure, no IO -------------------------------------------------
|
|
109
|
+
|
|
110
|
+
export function buildScale(config) {
|
|
111
|
+
const { typeBase, typeRatio, spaceBase, spaceRatio, leading, tracking } = config;
|
|
112
|
+
if (typeRatio <= 1 || typeRatio >= 2) {
|
|
113
|
+
throw new Error(`scale: typeRatio must be in (1, 2), got ${typeRatio}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const space = (mult) =>
|
|
117
|
+
gridScale(spaceBase, spaceRatio, SPACE_STEPS, { snap: snapToGrid, floor: 4, mult });
|
|
118
|
+
|
|
119
|
+
// Type: ascend headings geometrically (whole px), descend sub-body with a 12px
|
|
120
|
+
// floor. `bodyFloor` lifts the compact body to ≥14px (and re-floors caption/small
|
|
121
|
+
// off the lifted body) so density never pushes prose below legibility.
|
|
122
|
+
const size = (mult, bodyFloor = 0) => {
|
|
123
|
+
const bodyIdeal = Math.round(typeBase * mult); // unfloored, magnitude-scaled
|
|
124
|
+
const body = Math.max(bodyFloor, bodyIdeal); // reading floor (per-density)
|
|
125
|
+
const { caption, small } = descendType(body, typeRatio, 1); // sub-body from floored body
|
|
126
|
+
return {
|
|
127
|
+
caption,
|
|
128
|
+
small,
|
|
129
|
+
body,
|
|
130
|
+
h3: Math.round(bodyIdeal * typeRatio), // headings from the IDEAL
|
|
131
|
+
h2: Math.round(bodyIdeal * typeRatio ** 2),
|
|
132
|
+
h1: Math.round(bodyIdeal * typeRatio ** 3),
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Radius: its own small geometric series on 4px — 0, then 4·2^n → 0,4,8,16,
|
|
137
|
+
// plus the pill sentinel. Density-stable (generated once).
|
|
138
|
+
const radius = { none: 0, sm: 4, md: 8, lg: 16, pill: 999 };
|
|
139
|
+
|
|
140
|
+
// Body leading coupled to density (v2), floored for legibility. round2 keeps the
|
|
141
|
+
// value clean (1.6 − 0.1 = 1.4999998 in float → 1.5).
|
|
142
|
+
const round2 = (x) => Math.round(x * 100) / 100;
|
|
143
|
+
const leadBody = (delta) => Math.max(LEADING_FLOOR, round2(leading.body + delta));
|
|
144
|
+
|
|
145
|
+
// Size-coupled tracking (em), per heading, from the comfy heading sizes. One curve;
|
|
146
|
+
// ui's data is flat, editorial's is sloped → tighter as type grows. Density-stable.
|
|
147
|
+
const comfySize = size(1, BODY_FLOOR);
|
|
148
|
+
const track = (px) => round4(clamp(tracking.min, tracking.max, tracking.intercept + tracking.slope * px));
|
|
149
|
+
const tracks = {
|
|
150
|
+
h1: track(comfySize.h1),
|
|
151
|
+
h2: track(comfySize.h2),
|
|
152
|
+
h3: track(comfySize.h3),
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const comfy = {
|
|
156
|
+
space: space(1),
|
|
157
|
+
size: comfySize,
|
|
158
|
+
radius,
|
|
159
|
+
leading: { body: leading.body, tight: leading.tight },
|
|
160
|
+
tracking: tracks,
|
|
161
|
+
target: { min: 24 }, // a11y SC 2.5.8 — 24px (1.5rem), density-stable
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// The density blocks carry ONLY the density-responsive groups: space + size +
|
|
165
|
+
// body leading (v2). radius/tracking/target stay density-stable (:root only).
|
|
166
|
+
// Each multiplier is applied inside the build, never to the comfy output.
|
|
167
|
+
const compact = {
|
|
168
|
+
space: space(COMPACT_MULT),
|
|
169
|
+
size: size(COMPACT_MULT, COMPACT_BODY_FLOOR),
|
|
170
|
+
leading: { body: leadBody(-LEADING_DENSITY_DELTA) },
|
|
171
|
+
};
|
|
172
|
+
const spacious = {
|
|
173
|
+
space: space(SPACIOUS_MULT),
|
|
174
|
+
size: size(SPACIOUS_MULT, BODY_FLOOR),
|
|
175
|
+
leading: { body: leadBody(LEADING_DENSITY_DELTA) },
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const magMin = config.fluidMagnitude.min;
|
|
179
|
+
const fluidMin = {
|
|
180
|
+
comfy: { space: space(1 * magMin), size: size(1 * magMin, BODY_FLOOR) },
|
|
181
|
+
compact: { space: space(COMPACT_MULT * magMin), size: size(COMPACT_MULT * magMin, COMPACT_BODY_FLOOR) },
|
|
182
|
+
spacious: { space: space(SPACIOUS_MULT * magMin), size: size(SPACIOUS_MULT * magMin, BODY_FLOOR) },
|
|
183
|
+
};
|
|
184
|
+
return { comfy, compact, spacious, fluidMin };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- generateScale: mirrors generatePalette (calls buildScale, writes DTCG) ---
|
|
188
|
+
|
|
189
|
+
// Fixed-precision string output → deterministic, no float tails (13.6000001).
|
|
190
|
+
function num(n) {
|
|
191
|
+
return Number.isInteger(n) ? String(n) : String(Math.round(n * 1e4) / 1e4);
|
|
192
|
+
}
|
|
193
|
+
const remOf = (px) => `${num(px / 16)}rem`; // 16px === 1rem
|
|
194
|
+
const pxOf = (px) => `${num(px)}px`;
|
|
195
|
+
|
|
196
|
+
// Emit one fluid dimension as a zoom-safe clamp. min/max are px endpoints; vpMin/vpMax px.
|
|
197
|
+
// Zero-slope (min === max) collapses to a bare rem value so non-flexing tokens stay stable.
|
|
198
|
+
export function fluidDimension(minPx, maxPx, vpMin, vpMax) {
|
|
199
|
+
if (minPx === maxPx) return remOf(minPx);
|
|
200
|
+
const slopeVw = ((maxPx - minPx) / (vpMax - vpMin)) * 100;
|
|
201
|
+
const interceptPx = minPx - (slopeVw / 100) * vpMin;
|
|
202
|
+
return `clamp(${remOf(minPx)}, calc(${remOf(interceptPx)} + ${num(slopeVw)}vw), ${remOf(maxPx)})`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function spaceGroup(minArr, maxArr, vp) {
|
|
206
|
+
return Object.fromEntries(maxArr.map((maxPx, i) => [
|
|
207
|
+
String(i + 1),
|
|
208
|
+
{ $value: fluidDimension(minArr[i], maxPx, vp.min, vp.max), $type: 'dimension' },
|
|
209
|
+
]));
|
|
210
|
+
}
|
|
211
|
+
function sizeGroup(minSize, maxSize, vp) {
|
|
212
|
+
return Object.fromEntries(Object.keys(maxSize).map((k) => [
|
|
213
|
+
k,
|
|
214
|
+
{ $value: fluidDimension(minSize[k], maxSize[k], vp.min, vp.max), $type: 'dimension' },
|
|
215
|
+
]));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function generateScale(profileName, root = '.') {
|
|
219
|
+
const config = PROFILES[profileName];
|
|
220
|
+
if (!config) throw new Error(`scale: unknown profile '${profileName}'`);
|
|
221
|
+
const { comfy, compact, spacious, fluidMin } = buildScale(config);
|
|
222
|
+
const vp = config.viewport;
|
|
223
|
+
|
|
224
|
+
// :root pass — space + size in rem; radius + target in px; leading unitless;
|
|
225
|
+
// tracking em. Top-level DTCG paths (size/space/radius/…), never nested under
|
|
226
|
+
// scale.* — the CSS var name derives from the path, and the named consumers
|
|
227
|
+
// expect --space-*, --size-*, --radius-*, --leading-*, --tracking-*.
|
|
228
|
+
const scale = {
|
|
229
|
+
space: spaceGroup(fluidMin.comfy.space, comfy.space, vp),
|
|
230
|
+
size: sizeGroup(fluidMin.comfy.size, comfy.size, vp),
|
|
231
|
+
radius: Object.fromEntries(
|
|
232
|
+
Object.entries(comfy.radius).map(([k, v]) => [k, { $value: pxOf(v), $type: 'dimension' }]),
|
|
233
|
+
),
|
|
234
|
+
leading: {
|
|
235
|
+
body: { $value: num(comfy.leading.body), $type: 'number' },
|
|
236
|
+
tight: { $value: num(comfy.leading.tight), $type: 'number' },
|
|
237
|
+
},
|
|
238
|
+
tracking: Object.fromEntries(
|
|
239
|
+
Object.entries(comfy.tracking).map(([k, v]) => [k, { $value: `${num(v)}em`, $type: 'dimension' }]),
|
|
240
|
+
),
|
|
241
|
+
target: {
|
|
242
|
+
min: { $value: pxOf(comfy.target.min), $type: 'dimension' },
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// density passes — density-responsive groups ONLY (space + size + body leading).
|
|
247
|
+
// radius/tracking/target are omitted so they inherit from :root (density-stable).
|
|
248
|
+
const densityDtcg = (maxBlock, minBlock) => ({
|
|
249
|
+
space: spaceGroup(minBlock.space, maxBlock.space, vp),
|
|
250
|
+
size: sizeGroup(minBlock.size, maxBlock.size, vp),
|
|
251
|
+
leading: { body: { $value: num(maxBlock.leading.body), $type: 'number' } },
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Breakpoints + container max-widths: mode/density-independent px dimensions, so
|
|
255
|
+
// they ride the :root/static pass exactly like radius/target (never a density block).
|
|
256
|
+
const bpDtcg = {
|
|
257
|
+
bp: Object.fromEntries(Object.entries(config.breakpoints).map(
|
|
258
|
+
([k, v]) => [k, { $value: pxOf(v), $type: 'dimension' }])),
|
|
259
|
+
container: Object.fromEntries(Object.entries(config.container).map(
|
|
260
|
+
([k, v]) => [k, { $value: pxOf(v), $type: 'dimension' }])),
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
mkdirSync(join(root, 'tokens'), { recursive: true });
|
|
264
|
+
writeFileSync(join(root, 'tokens/scale.json'), JSON.stringify(scale, null, 2) + '\n');
|
|
265
|
+
writeFileSync(join(root, 'tokens/scale.compact.json'), JSON.stringify(densityDtcg(compact, fluidMin.compact), null, 2) + '\n');
|
|
266
|
+
writeFileSync(join(root, 'tokens/scale.spacious.json'), JSON.stringify(densityDtcg(spacious, fluidMin.spacious), null, 2) + '\n');
|
|
267
|
+
writeFileSync(join(root, 'tokens/breakpoints.json'), JSON.stringify(bpDtcg, null, 2) + '\n');
|
|
268
|
+
}
|
package/src/store.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, basename, extname } from 'node:path';
|
|
3
|
+
import matter from 'gray-matter';
|
|
4
|
+
import { marked } from 'marked';
|
|
5
|
+
|
|
6
|
+
marked.setOptions({ mangle: false, headerIds: false }); // deterministic output
|
|
7
|
+
|
|
8
|
+
function loadMd(file) {
|
|
9
|
+
const { data, content } = matter(readFileSync(file, 'utf8'));
|
|
10
|
+
// gray-matter parses YAML dates to Date objects; coerce to an ISO string so
|
|
11
|
+
// templates can safely render {{ ...date }} and lexicographic sort still works.
|
|
12
|
+
if (data.date instanceof Date) data.date = data.date.toISOString().slice(0, 10);
|
|
13
|
+
return { ...data, html: marked.parse(content).trim() };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function loadStore(dir) {
|
|
17
|
+
const store = {};
|
|
18
|
+
for (const name of readdirSync(dir)) {
|
|
19
|
+
if (name.startsWith('.') || name === 'README.md') continue; // skip OS cruft + docs
|
|
20
|
+
const full = join(dir, name);
|
|
21
|
+
const ext = extname(name);
|
|
22
|
+
const key = basename(name, ext);
|
|
23
|
+
if (ext === '.json') {
|
|
24
|
+
try { store[key] = JSON.parse(readFileSync(full, 'utf8')); }
|
|
25
|
+
catch (e) { throw new Error(`store: bad JSON in ${name}: ${e.message}`); }
|
|
26
|
+
} else if (ext === '.md') {
|
|
27
|
+
store[key] = loadMd(full);
|
|
28
|
+
} else if (name === 'journal') {
|
|
29
|
+
store.journal = readdirSync(full)
|
|
30
|
+
.filter((f) => f.endsWith('.md'))
|
|
31
|
+
.map((f) => ({ slug: basename(f, '.md'), ...loadMd(join(full, f)) }))
|
|
32
|
+
.sort((a, b) => new Date(b.date) - new Date(a.date));
|
|
33
|
+
} else {
|
|
34
|
+
throw new Error(`store: unexpected entry in content dir: ${name}`); // fail-loud on typos
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return store;
|
|
38
|
+
}
|
package/src/tokens.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// src/tokens.mjs — DTCG token sources → CSS.
|
|
2
|
+
// :root (dark) ← base + semantic + palette.dark + accent.dark + SCALE
|
|
3
|
+
// [data-mode="light"] ← base + semantic + palette.light + accent.light
|
|
4
|
+
// [data-density="compact"]← scale.compact (space + size only)
|
|
5
|
+
//
|
|
6
|
+
// Scale tokens are mode-independent → emitted in the :root pass ONLY (T3); the
|
|
7
|
+
// compact block carries only the density-responsive groups and is concatenated
|
|
8
|
+
// AFTER the mode blocks so its --space-*/--size-* overrides win (the ordering
|
|
9
|
+
// contract). The compact block is emitted by a direct string templater (flat
|
|
10
|
+
// values, no refs) so Style Dictionary owns the mode/color axis and the runtime
|
|
11
|
+
// density axis stays a plain cascade override — SD never has to model a second
|
|
12
|
+
// selector dimension.
|
|
13
|
+
import StyleDictionary from 'style-dictionary';
|
|
14
|
+
import { readFileSync, writeFileSync, rmSync, mkdirSync, existsSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
function configFor(root, mode, selector, outDir, extraSources = [], overrideDir = null) {
|
|
18
|
+
const baseNames = ['base.json', 'semantic.json', `palette.${mode}.json`, `accent.${mode}.json`];
|
|
19
|
+
const base = baseNames.map((n) => join(root, 'tokens', n));
|
|
20
|
+
// Override files (same basename) are appended AFTER base + scale so Style
|
|
21
|
+
// Dictionary's later-source-wins resolves to the consumer's value.
|
|
22
|
+
const overrides = overrideDir
|
|
23
|
+
? baseNames.map((n) => join(overrideDir, n)).filter((p) => existsSync(p))
|
|
24
|
+
: [];
|
|
25
|
+
return {
|
|
26
|
+
usesDtcg: true,
|
|
27
|
+
source: [...base, ...extraSources, ...overrides],
|
|
28
|
+
platforms: { css: { transformGroup: 'css', buildPath: outDir.endsWith('/') ? outDir : outDir + '/',
|
|
29
|
+
files: [{ destination: `tokens.${mode}.css`, format: 'css/variables',
|
|
30
|
+
options: { selector, outputReferences: false } }] } }
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Flatten a scale DTCG group ({ space:{1:{$value}}, size:{body:{$value}} }) into
|
|
35
|
+
// CSS custom-property declarations: --space-1, --size-body, …
|
|
36
|
+
function scaleVars(scale) {
|
|
37
|
+
const decls = [];
|
|
38
|
+
for (const [group, tokens] of Object.entries(scale)) {
|
|
39
|
+
for (const [key, tok] of Object.entries(tokens)) {
|
|
40
|
+
decls.push(` --${group}-${key}: ${tok.$value};`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return decls.join('\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// One runtime density override block. Emitted by a direct string templater (flat
|
|
47
|
+
// values, no refs) so Style Dictionary owns only the mode/color axis; each density
|
|
48
|
+
// stays a plain cascade override layered after the mode blocks.
|
|
49
|
+
function densityBlock(root, density) {
|
|
50
|
+
const path = join(root, `tokens/scale.${density}.json`);
|
|
51
|
+
if (!existsSync(path)) return '';
|
|
52
|
+
const scale = JSON.parse(readFileSync(path, 'utf8'));
|
|
53
|
+
return `[data-density="${density}"] {\n${scaleVars(scale)}\n}\n`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function buildTokens(root = '.', outDir = join(root, 'dist'), overrideDir = null) {
|
|
57
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
58
|
+
mkdirSync(outDir, { recursive: true });
|
|
59
|
+
// Scale tokens ride the :root/dark pass only (mode-independent). Breakpoints +
|
|
60
|
+
// container widths are likewise mode/density-independent static dimensions, so
|
|
61
|
+
// they join the same static source set (→ --bp-*/--container-* at :root).
|
|
62
|
+
const scalePath = join(root, 'tokens/scale.json');
|
|
63
|
+
const bpPath = join(root, 'tokens/breakpoints.json');
|
|
64
|
+
const scaleSource = [
|
|
65
|
+
...(existsSync(scalePath) ? [scalePath] : []),
|
|
66
|
+
...(existsSync(bpPath) ? [bpPath] : []),
|
|
67
|
+
];
|
|
68
|
+
for (const [mode, selector] of [['dark', ':root'], ['light', '[data-mode="light"]']]) {
|
|
69
|
+
const sd = new StyleDictionary(configFor(root, mode, selector, outDir,
|
|
70
|
+
mode === 'dark' ? scaleSource : [], overrideDir));
|
|
71
|
+
await sd.buildAllPlatforms();
|
|
72
|
+
}
|
|
73
|
+
const css = `${readFileSync(join(outDir, 'tokens.dark.css'), 'utf8')}\n`
|
|
74
|
+
+ `${readFileSync(join(outDir, 'tokens.light.css'), 'utf8')}\n`
|
|
75
|
+
+ densityBlock(root, 'compact')
|
|
76
|
+
+ densityBlock(root, 'spacious');
|
|
77
|
+
writeFileSync(join(outDir, 'tokens.css'), css);
|
|
78
|
+
return join(outDir, 'tokens.css');
|
|
79
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// drizzle.config.mjs — shared drizzle-kit config for both providers.
|
|
2
|
+
import { defineConfig } from 'drizzle-kit';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
dialect: 'postgresql',
|
|
6
|
+
schema: './db/schema.mjs',
|
|
7
|
+
out: './drizzle',
|
|
8
|
+
dbCredentials: { url: process.env.DATABASE_URL },
|
|
9
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// db/client.mjs — Neon serverless (neon-http) data client. Default provider.
|
|
2
|
+
// Reads the pooled DATABASE_URL; neon() is lazy, so no socket opens at import.
|
|
3
|
+
import { drizzle } from 'drizzle-orm/neon-http';
|
|
4
|
+
import { neon } from '@neondatabase/serverless';
|
|
5
|
+
import * as schema from './schema.mjs';
|
|
6
|
+
|
|
7
|
+
export const db = drizzle(neon(process.env.DATABASE_URL), { schema });
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Neon (default provider) — use the POOLED connection string for serverless.
|
|
2
|
+
# The host must contain `-pooler`; a direct host will be flagged at setup.
|
|
3
|
+
DATABASE_URL=postgresql://user:password@ep-example-pooler.us-east-1.aws.neon.tech/dbname?sslmode=require
|
|
4
|
+
|
|
5
|
+
# Embedding width. 1536 = OpenAI text-embedding-3-small. Must match your schema.
|
|
6
|
+
EMBEDDING_DIM=1536
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// db/client.mjs — Supabase (postgres-js) data client. Opt-in provider.
|
|
2
|
+
// prepare:false is required for Supabase's transaction-mode pooler (port 6543).
|
|
3
|
+
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
4
|
+
import postgres from 'postgres';
|
|
5
|
+
import * as schema from './schema.mjs';
|
|
6
|
+
|
|
7
|
+
export const db = drizzle(postgres(process.env.DATABASE_URL, { prepare: false }), { schema });
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Supabase local config — minimal. Generated by the helical scaffolder.
|
|
2
|
+
project_id = "helical-app"
|
|
3
|
+
|
|
4
|
+
[db]
|
|
5
|
+
port = 54322
|
|
6
|
+
major_version = 15
|
|
7
|
+
|
|
8
|
+
[db.pooler]
|
|
9
|
+
enabled = true
|
|
10
|
+
# Transaction mode is what the postgres-js client connects through (port 6543).
|
|
11
|
+
default_pool_mode = "transaction"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Supabase (opt-in provider) — use the transaction-mode POOLER (port 6543).
|
|
2
|
+
DATABASE_URL=postgresql://postgres.project:password@aws-0-us-east-1.pooler.supabase.com:6543/postgres
|
|
3
|
+
|
|
4
|
+
# Server-side privileged key. Keep secret; never expose to the client.
|
|
5
|
+
SUPABASE_SERVICE_ROLE_KEY=
|
|
6
|
+
|
|
7
|
+
# Embedding width. 1536 = OpenAI text-embedding-3-small. Must match your schema.
|
|
8
|
+
EMBEDDING_DIM=1536
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- 0001_init.sql — pgvector + the documents table + an HNSW cosine index.
|
|
2
|
+
CREATE EXTENSION IF NOT EXISTS vector;
|
|
3
|
+
|
|
4
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
5
|
+
id serial PRIMARY KEY,
|
|
6
|
+
body text NOT NULL,
|
|
7
|
+
embedding vector(1536) NOT NULL
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
-- Approximate nearest-neighbour index used by the cosine `<=>` query.
|
|
11
|
+
CREATE INDEX IF NOT EXISTS documents_embedding_idx
|
|
12
|
+
ON documents USING hnsw (embedding vector_cosine_ops);
|