@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.
Files changed (56) hide show
  1. package/README.md +23 -0
  2. package/bin/create-helical.mjs +59 -0
  3. package/package.json +40 -0
  4. package/src/build-all.mjs +59 -0
  5. package/src/build.mjs +31 -0
  6. package/src/content.mjs +6 -0
  7. package/src/db/client.mjs +45 -0
  8. package/src/db/schema.mjs +15 -0
  9. package/src/db/vector.mjs +10 -0
  10. package/src/harness.mjs +449 -0
  11. package/src/index.mjs +19 -0
  12. package/src/pages.mjs +14 -0
  13. package/src/palette.mjs +39 -0
  14. package/src/registry.mjs +29 -0
  15. package/src/render.mjs +43 -0
  16. package/src/scaffold.mjs +200 -0
  17. package/src/scale.mjs +268 -0
  18. package/src/store.mjs +38 -0
  19. package/src/tokens.mjs +79 -0
  20. package/templates/db/_shared/drizzle.config.mjs +9 -0
  21. package/templates/db/neon/client.mjs +7 -0
  22. package/templates/db/neon/env.example +6 -0
  23. package/templates/db/supabase/client.mjs +7 -0
  24. package/templates/db/supabase/config.toml +11 -0
  25. package/templates/db/supabase/env.example +8 -0
  26. package/templates/db/supabase/migrations/0001_init.sql +12 -0
  27. package/templates/profiles/_base/README.md +22 -0
  28. package/templates/profiles/_base/assets/base.css +50 -0
  29. package/templates/profiles/_base/content/about.md +4 -0
  30. package/templates/profiles/_base/content/site.json +1 -0
  31. package/templates/profiles/_base/pages.json +4 -0
  32. package/templates/profiles/_base/templates/layout.html +1 -0
  33. package/templates/profiles/_base/templates/pages/about.html +2 -0
  34. package/templates/profiles/_base/templates/pages/home.html +3 -0
  35. package/templates/profiles/_base/tokens/accent.dark.json +7 -0
  36. package/templates/profiles/_base/tokens/accent.light.json +7 -0
  37. package/templates/profiles/_base/tokens/base.json +18 -0
  38. package/templates/profiles/_base/tokens/breakpoints.json +38 -0
  39. package/templates/profiles/_base/tokens/palette.dark.json +96 -0
  40. package/templates/profiles/_base/tokens/palette.light.json +96 -0
  41. package/templates/profiles/_base/tokens/scale.compact.json +68 -0
  42. package/templates/profiles/_base/tokens/scale.json +114 -0
  43. package/templates/profiles/_base/tokens/scale.spacious.json +68 -0
  44. package/templates/profiles/_base/tokens/semantic.json +14 -0
  45. package/templates/profiles/app/.keep +0 -0
  46. package/templates/profiles/marketing/.keep +0 -0
  47. package/tokens/accent.dark.json +7 -0
  48. package/tokens/accent.light.json +7 -0
  49. package/tokens/base.json +18 -0
  50. package/tokens/breakpoints.json +38 -0
  51. package/tokens/palette.dark.json +96 -0
  52. package/tokens/palette.light.json +96 -0
  53. package/tokens/scale.compact.json +68 -0
  54. package/tokens/scale.json +114 -0
  55. package/tokens/scale.spacious.json +68 -0
  56. package/tokens/semantic.json +14 -0
@@ -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);