getfilepress 0.1.10 → 0.1.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getfilepress",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "FilePress — file-based Markdown blog engine. Link this package from a content-only site (config + posts), then run `filepress build`.",
@@ -554,6 +554,10 @@
554
554
  {loading ? `Asking Ollama… ${formatWait(jobSecs)}` : 'Refine & activate'}
555
555
  </button>
556
556
  <p class="genie-muted">
557
+ The raw Ollama JSON is printed in the <code>filepress dev</code> terminal and saved to
558
+ <code>.filepress-genie/last-ollama.json</code>. “Icy / Antarctica / bright” means a
559
+ light page — activate <strong>baseline</strong> in History first if a prior dark look
560
+ is still the seed.
557
561
  <code>gemma4:12b</code> often spends the first few minutes loading into VRAM — that is
558
562
  normal. Progress prints in the filepress terminal every 15s. Raise the budget with
559
563
  <code>FILEPRESS_OLLAMA_TIMEOUT_MS</code> (default 10 minutes). Tip: set
@@ -25,6 +25,7 @@ import type { DesignBrief, SiteIR } from '../../../../import/src/ir.ts';
25
25
  import {
26
26
  activateVersion,
27
27
  ensureBaseline,
28
+ genieRoot,
28
29
  getActive,
29
30
  listVersions,
30
31
  readVersionBrief,
@@ -416,7 +417,10 @@ export async function refineWithOllama(
416
417
  inspireSignals: [],
417
418
  seed,
418
419
  strictParse: true,
419
- logLabel
420
+ logLabel,
421
+ intent: 'steer',
422
+ steer: prompt,
423
+ dumpPath: join(genieRoot(siteRoot), 'last-ollama.json')
420
424
  });
421
425
  const themeCss = themeCssFromBrief(brief);
422
426
  const meta = writeSnapshot(siteRoot, {
@@ -1,7 +1,170 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
1
3
  import type { DesignBrief, SiteIR } from './ir.ts';
2
4
  import type { InspirationSignals } from './inspire.ts';
3
5
  import { DEFAULT_BRIEF, parseBriefJson } from './theme.ts';
4
6
 
7
+ export type BriefIntent = 'import' | 'steer';
8
+
9
+ const BRIEF_SHAPE = `{
10
+ "mood": "short phrase",
11
+ "do": ["..."],
12
+ "dont": ["..."],
13
+ "tokens": {
14
+ "accent": "#rrggbb",
15
+ "accentStrong": "#rrggbb",
16
+ "bg": "#rrggbb",
17
+ "ink": "#rrggbb",
18
+ "inkSoft": "#rrggbb",
19
+ "surface": "#rrggbb",
20
+ "rule": "#rrggbb",
21
+ "ruleStrong": "#rrggbb"
22
+ },
23
+ "density": "sparse" | "balanced" | "dense",
24
+ "paletteMode": "dark" | "light",
25
+ "fonts": {
26
+ "serif": "Font Name",
27
+ "sans": "Font Name",
28
+ "mono": "Font Name",
29
+ "googleHref": "https://fonts.googleapis.com/css2?..." or null
30
+ },
31
+ "hero": "bold" | "editorial",
32
+ "atmosphere": "noise" | "none",
33
+ "navStyle": "uppercase-tracked" | "soft",
34
+ "elevatedCards": true | false,
35
+ "cssNotes": ["..."]
36
+ }`;
37
+
38
+ /** Paper / ice defaults when the author asked for light and the model still returns a cave. */
39
+ export const LIGHT_FLOOR = {
40
+ bg: '#f3f7fb',
41
+ surface: '#ffffff',
42
+ ink: '#16324a',
43
+ inkSoft: '#3d5a70',
44
+ rule: '#c5d6e4',
45
+ ruleStrong: '#8eafc8',
46
+ accent: '#1a6fa8',
47
+ accentStrong: '#0e4d7a'
48
+ };
49
+
50
+ export function paletteHintFromSteer(text: string): 'light' | 'dark' | null {
51
+ const t = text.toLowerCase();
52
+ const wantsLight =
53
+ /\b(light|bright|white|snow|ice|icy|antarctica|antarctic|arctic|glacier|frost|daylight|crisp|parchment|paper)\b/.test(
54
+ t
55
+ );
56
+ const wantsDark = /\b(dark|night|black|midnight|noir|charcoal|obsidian)\b/.test(t);
57
+ if (wantsLight && !wantsDark) return 'light';
58
+ if (wantsDark && !wantsLight) return 'dark';
59
+ if (wantsLight) return 'light';
60
+ if (wantsDark) return 'dark';
61
+ return null;
62
+ }
63
+
64
+ export function hexRelativeLuminance(hex: string | undefined): number | null {
65
+ if (!hex) return null;
66
+ const m = hex.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
67
+ if (!m) return null;
68
+ let h = m[1];
69
+ if (h.length === 3) h = [...h].map((c) => `${c}${c}`).join('');
70
+ const n = parseInt(h, 16);
71
+ const r = (n >> 16) & 255;
72
+ const g = (n >> 8) & 255;
73
+ const b = n & 255;
74
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
75
+ }
76
+
77
+ export function applyPaletteHint(brief: DesignBrief, hint: 'light' | 'dark' | null): DesignBrief {
78
+ if (!hint) return brief;
79
+ const tokens = { ...brief.tokens };
80
+ if (hint === 'light') {
81
+ const bgLum = hexRelativeLuminance(tokens.bg);
82
+ const inkLum = hexRelativeLuminance(tokens.ink);
83
+ if (bgLum === null || bgLum < 0.75) {
84
+ tokens.bg = LIGHT_FLOOR.bg;
85
+ tokens.surface = LIGHT_FLOOR.surface;
86
+ tokens.rule = tokens.rule && (hexRelativeLuminance(tokens.rule) ?? 0) > 0.55 ? tokens.rule : LIGHT_FLOOR.rule;
87
+ tokens.ruleStrong =
88
+ tokens.ruleStrong && (hexRelativeLuminance(tokens.ruleStrong) ?? 0) > 0.4
89
+ ? tokens.ruleStrong
90
+ : LIGHT_FLOOR.ruleStrong;
91
+ }
92
+ if (inkLum === null || inkLum > 0.45) {
93
+ tokens.ink = LIGHT_FLOOR.ink;
94
+ tokens.inkSoft = LIGHT_FLOOR.inkSoft;
95
+ }
96
+ return { ...brief, paletteMode: 'light', tokens };
97
+ }
98
+ return { ...brief, paletteMode: 'dark' };
99
+ }
100
+
101
+ export function buildDesignBriefPrompt(opts: {
102
+ intent: BriefIntent;
103
+ seed: DesignBrief;
104
+ ir: SiteIR;
105
+ inspireSummaries: string[];
106
+ inspireSignals: InspirationSignals[];
107
+ steer?: string;
108
+ }): { system: string; user: string } {
109
+ const notes = opts.inspireSignals.flatMap((s) => s.notes).join('; ') || '(none)';
110
+ const snippets =
111
+ opts.inspireSummaries.map((s, i) => `(${i + 1}) ${s}`).join('\n') || '(none)';
112
+ if (opts.intent === 'steer') {
113
+ return {
114
+ system:
115
+ 'You output only valid JSON. Honor the author\'s written palette. Light, ice, snow, or Antarctica means a bright page — never a black one unless they also say dark or night.',
116
+ user: `You are restyling a filepress Essay site from the author's written direction.
117
+ The seed is only a starting point. When the direction conflicts with the seed, the direction wins.
118
+ Return ONLY JSON (no fences) with this shape:
119
+ ${BRIEF_SHAPE}
120
+
121
+ Hard rules:
122
+ - Follow the author's palette. Light / bright / white / snow / ice / Antarctica / daylight → paletteMode "light", background #e8 or lighter (paper, snow, ice), dark readable ink. Do not use near-black backgrounds.
123
+ - "Cold" or "icy" is color temperature (blue, white, crisp) — not a night scene unless they also say dark/night/black.
124
+ - Dark/night/black only when the author asks for that.
125
+ - Do not invent marketing section layouts.
126
+ - Avoid purple-on-white clichés.
127
+
128
+ Author direction:
129
+ ${opts.steer?.trim() || '(none)'}
130
+
131
+ Seed brief (override freely when the direction conflicts):
132
+ ${JSON.stringify(opts.seed, null, 2)}
133
+
134
+ Site identity (content only — do not force its old palette):
135
+ ${JSON.stringify(opts.ir.identity, null, 2)}
136
+ `
137
+ };
138
+ }
139
+ return {
140
+ system:
141
+ 'You output only valid JSON. Preserve dark inspiration palettes; do not flatten them into cream editorial.',
142
+ user: `You are a design director restyling a personal Markdown blog (filepress Essay chrome).
143
+ The seed brief below was extracted from inspiration site CSS/fonts. Refine it — keep the punch.
144
+ Return ONLY JSON (no fences) with this shape:
145
+ ${BRIEF_SHAPE}
146
+
147
+ Hard rules:
148
+ - If inspiration is dark/modern, paletteMode MUST stay "dark" with bold hero + noise + tracked nav.
149
+ - Personal essay site: do NOT invent marketing section layouts.
150
+ - Prefer inspiration fonts/colors over the source site's cream-editorial look.
151
+ - Avoid purple-on-white clichés.
152
+
153
+ Seed brief (from inspiration extraction):
154
+ ${JSON.stringify(opts.seed, null, 2)}
155
+
156
+ Site identity (content only — do not force its old palette):
157
+ ${JSON.stringify(opts.ir.identity, null, 2)}
158
+
159
+ Inspiration notes:
160
+ ${notes}
161
+
162
+ Inspiration text snippets:
163
+ ${snippets}
164
+ `
165
+ };
166
+ }
167
+
5
168
  /** Strip trailing slashes; used to compare Genie picker values with env. */
6
169
  export function normalizeOllamaHost(host: string): string {
7
170
  return host.trim().replace(/\/+$/, '');
@@ -147,62 +310,28 @@ export async function generateDesignBrief(opts: {
147
310
  /** When true, unparseable model JSON fails instead of silently using the seed. */
148
311
  strictParse?: boolean;
149
312
  logLabel?: string;
313
+ /** `steer` uses the author-direction prompt; default is the import/inspire prompt. */
314
+ intent?: BriefIntent;
315
+ steer?: string;
316
+ /** Write raw + applied brief JSON here (Genie: `.filepress-genie/last-ollama.json`). */
317
+ dumpPath?: string;
150
318
  }): Promise<DesignBrief> {
151
319
  const host = opts.host.replace(/\/+$/, '');
152
320
  const timeoutMs = ollamaChatTimeoutMs();
153
321
  const started = Date.now();
154
322
  const logLabel = opts.logLabel ?? `filepress: Ollama ${opts.model} @ ${host}`;
155
- console.log(`${logLabel}: starting chat (up to ${Math.round(timeoutMs / 1000)}s)`);
156
- const prompt = `You are a design director restyling a personal Markdown blog (filepress Essay chrome).
157
- The seed brief below was extracted from inspiration site CSS/fonts. Refine it — keep the punch.
158
- Return ONLY JSON (no fences) with this shape:
159
- {
160
- "mood": "short phrase",
161
- "do": ["..."],
162
- "dont": ["..."],
163
- "tokens": {
164
- "accent": "#rrggbb",
165
- "accentStrong": "#rrggbb",
166
- "bg": "#rrggbb",
167
- "ink": "#rrggbb",
168
- "inkSoft": "#rrggbb",
169
- "surface": "#rrggbb",
170
- "rule": "#rrggbb",
171
- "ruleStrong": "#rrggbb"
172
- },
173
- "density": "sparse" | "balanced" | "dense",
174
- "paletteMode": "dark" | "light",
175
- "fonts": {
176
- "serif": "Font Name",
177
- "sans": "Font Name",
178
- "mono": "Font Name",
179
- "googleHref": "https://fonts.googleapis.com/css2?..." or null
180
- },
181
- "hero": "bold" | "editorial",
182
- "atmosphere": "noise" | "none",
183
- "navStyle": "uppercase-tracked" | "soft",
184
- "elevatedCards": true | false,
185
- "cssNotes": ["..."]
186
- }
187
-
188
- Hard rules:
189
- - If inspiration is dark/modern, paletteMode MUST stay "dark" with bold hero + noise + tracked nav.
190
- - Personal essay site: do NOT invent marketing section layouts.
191
- - Prefer inspiration fonts/colors over the source site's cream-editorial look.
192
- - Avoid purple-on-white clichés.
193
-
194
- Seed brief (from inspiration extraction):
195
- ${JSON.stringify(opts.seed, null, 2)}
196
-
197
- Site identity (content only — do not force its old palette):
198
- ${JSON.stringify(opts.ir.identity, null, 2)}
199
-
200
- Inspiration notes:
201
- ${opts.inspireSignals.flatMap((s) => s.notes).join('; ') || '(none)'}
202
-
203
- Inspiration text snippets:
204
- ${opts.inspireSummaries.map((s, i) => `(${i + 1}) ${s}`).join('\n') || '(none)'}
205
- `;
323
+ const intent = opts.intent ?? 'import';
324
+ const hint = intent === 'steer' ? paletteHintFromSteer(opts.steer ?? '') : null;
325
+ const messages = buildDesignBriefPrompt({
326
+ intent,
327
+ seed: opts.seed,
328
+ ir: opts.ir,
329
+ inspireSummaries: opts.inspireSummaries,
330
+ inspireSignals: opts.inspireSignals,
331
+ steer: opts.steer
332
+ });
333
+ console.log(`${logLabel}: starting chat (up to ${Math.round(timeoutMs / 1000)}s) intent=${intent}`);
334
+ if (hint) console.log(`${logLabel}: author palette hint = ${hint}`);
206
335
 
207
336
  let content = '';
208
337
  try {
@@ -215,12 +344,8 @@ ${opts.inspireSummaries.map((s, i) => `(${i + 1}) ${s}`).join('\n') || '(none)'}
215
344
  format: 'json',
216
345
  options: { temperature: 0.35 },
217
346
  messages: [
218
- {
219
- role: 'system',
220
- content:
221
- 'You output only valid JSON. Preserve dark inspiration palettes; do not flatten them into cream editorial.'
222
- },
223
- { role: 'user', content: prompt }
347
+ { role: 'system', content: messages.system },
348
+ { role: 'user', content: messages.user }
224
349
  ]
225
350
  }),
226
351
  signal: AbortSignal.timeout(timeoutMs)
@@ -233,6 +358,7 @@ ${opts.inspireSummaries.map((s, i) => `(${i + 1}) ${s}`).join('\n') || '(none)'}
233
358
 
234
359
  content = await readOllamaChatStream(res, logLabel, started);
235
360
  console.log(`${logLabel}: done in ${Math.round((Date.now() - started) / 1000)}s (${content.length} chars)`);
361
+ console.log(`${logLabel}: raw JSON\n${content}`);
236
362
  } catch (e) {
237
363
  if (isAbortLike(e)) {
238
364
  throw new Error(ollamaTimeoutMessage({ host, model: opts.model, timeoutMs }));
@@ -242,19 +368,56 @@ ${opts.inspireSummaries.map((s, i) => `(${i + 1}) ${s}`).join('\n') || '(none)'}
242
368
  try {
243
369
  const refined = parseBriefJson(content);
244
370
  // Never let the model drop fonts/googleHref from a rich seed
245
- return {
371
+ let brief: DesignBrief = {
246
372
  ...opts.seed,
247
373
  ...refined,
248
374
  tokens: { ...opts.seed.tokens, ...refined.tokens },
249
375
  fonts: refined.fonts || opts.seed.fonts,
250
- paletteMode: refined.paletteMode || opts.seed.paletteMode,
376
+ paletteMode: refined.paletteMode || (intent === 'steer' ? undefined : opts.seed.paletteMode),
251
377
  hero: refined.hero || opts.seed.hero,
252
378
  atmosphere: refined.atmosphere || opts.seed.atmosphere,
253
379
  navStyle: refined.navStyle || opts.seed.navStyle,
254
380
  elevatedCards: refined.elevatedCards ?? opts.seed.elevatedCards
255
381
  };
382
+ brief = applyPaletteHint(brief, hint);
383
+ console.log(
384
+ `${logLabel}: applied paletteMode=${brief.paletteMode ?? '(unset)'} bg=${brief.tokens.bg ?? '(unset)'} ink=${brief.tokens.ink ?? '(unset)'} accent=${brief.tokens.accent}`
385
+ );
386
+ if (opts.dumpPath) {
387
+ mkdirSync(dirname(opts.dumpPath), { recursive: true });
388
+ writeFileSync(
389
+ opts.dumpPath,
390
+ `${JSON.stringify(
391
+ {
392
+ intent,
393
+ steer: opts.steer ?? null,
394
+ hint,
395
+ model: opts.model,
396
+ host,
397
+ raw: content,
398
+ applied: {
399
+ paletteMode: brief.paletteMode,
400
+ tokens: brief.tokens,
401
+ mood: brief.mood
402
+ }
403
+ },
404
+ null,
405
+ 2
406
+ )}\n`
407
+ );
408
+ console.log(`${logLabel}: dumped ${opts.dumpPath}`);
409
+ }
410
+ return brief;
256
411
  } catch (e) {
257
412
  const detail = e instanceof Error ? e.message : String(e);
413
+ if (opts.dumpPath && content) {
414
+ mkdirSync(dirname(opts.dumpPath), { recursive: true });
415
+ writeFileSync(
416
+ opts.dumpPath,
417
+ `${JSON.stringify({ intent, steer: opts.steer ?? null, parseError: detail, raw: content }, null, 2)}\n`
418
+ );
419
+ console.log(`${logLabel}: dumped unparsed response to ${opts.dumpPath}`);
420
+ }
258
421
  if (opts.strictParse) {
259
422
  throw new Error(
260
423
  `Ollama returned a brief we could not parse (${detail}). First 200 chars: ${content.slice(0, 200)}`