sapiens-mcp 1.69.1 → 1.70.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/dist/clientIdentity.js +41 -0
- package/dist/index.js +4 -1
- package/dist/registry.js +3 -2
- package/dist/remote.js +5 -1
- package/dist/resume/coverLetter.js +106 -106
- package/dist/resume/template.js +690 -690
- package/dist/skills.js +72 -0
- package/dist/tools/character.js +3 -0
- package/dist/tools/gallery.js +171 -24
- package/dist/tools/image.js +19 -2
- package/dist/tools/meta.js +106 -2
- package/package.json +1 -1
- package/dist/tools/referencia.js +0 -114
- package/dist/tools/shorts.js +0 -81
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// ============================================
|
|
2
|
+
// QUEM É O CLIENT — o motor que a pessoa usa do outro lado do MCP.
|
|
3
|
+
//
|
|
4
|
+
// O backend quer saber por onde um personagem nasceu ou foi ativado ("MCP
|
|
5
|
+
// (Claude)" em vez de "MCP (agente)" no alerta do dono). Dois transportes,
|
|
6
|
+
// duas fontes:
|
|
7
|
+
// - stdio: o handshake `initialize` traz clientInfo {name, version} e o
|
|
8
|
+
// Server do SDK guarda em getClientVersion(). É a fonte boa: "claude-ai",
|
|
9
|
+
// "claude-code", "cursor", "gemini-cli"...
|
|
10
|
+
// - remoto (streamable HTTP, stateless na Vercel): cada request é um Server
|
|
11
|
+
// novo e o initialize não fica. Sobra o User-Agent do HTTP, que viaja com
|
|
12
|
+
// o prefixo "ua:" pro backend saber que é pista, não nome.
|
|
13
|
+
// O valor cru vai pro backend, que rotula (convex/shared/characterOrigin.ts).
|
|
14
|
+
// Nunca é identidade: a identidade é sempre o bearer/login.
|
|
15
|
+
// ============================================
|
|
16
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
17
|
+
const MAX = 120;
|
|
18
|
+
const requestClient = new AsyncLocalStorage();
|
|
19
|
+
/** Limpa o que veio de fora: uma linha, sem caractere de controle, teto de 120. */
|
|
20
|
+
export function sanitizeClientName(raw) {
|
|
21
|
+
if (typeof raw !== "string")
|
|
22
|
+
return undefined;
|
|
23
|
+
const clean = raw
|
|
24
|
+
.replace(/[\u0000-\u001f\u007f]+/g, " ")
|
|
25
|
+
.replace(/\s+/g, " ")
|
|
26
|
+
.trim();
|
|
27
|
+
if (!clean)
|
|
28
|
+
return undefined;
|
|
29
|
+
return clean.slice(0, MAX);
|
|
30
|
+
}
|
|
31
|
+
/** Embrulha a chamada com a pista do client (call do stdio ou request remota). */
|
|
32
|
+
export function runWithClientHint(hint, fn) {
|
|
33
|
+
const clean = sanitizeClientName(hint);
|
|
34
|
+
if (!clean)
|
|
35
|
+
return fn();
|
|
36
|
+
return requestClient.run(clean, fn);
|
|
37
|
+
}
|
|
38
|
+
/** A pista do client da chamada em curso, se houver. */
|
|
39
|
+
export function getClientName() {
|
|
40
|
+
return requestClient.getStore();
|
|
41
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { getCachedTier, onTierVisibilityChange, probeTierInBackground, } from ".
|
|
|
13
13
|
import { getPrompt, listPrompts } from "./prompts.js";
|
|
14
14
|
import { listSkillResources, readSkillResource } from "./skills.js";
|
|
15
15
|
import { convexMutation, getSessionToken } from "./convexClient.js";
|
|
16
|
+
import { runWithClientHint } from "./clientIdentity.js";
|
|
16
17
|
// Telemetria stdio (fire-and-forget, NUNCA derruba a chamada). O stdio sempre
|
|
17
18
|
// foi caixa-preta (cada cliente roda a própria cópia via npx), então o
|
|
18
19
|
// transporte que mais roda — Helen/Cursor/Gemini CLI — era invisível na
|
|
@@ -77,7 +78,9 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
77
78
|
const name = req.params.name;
|
|
78
79
|
const args = (req.params.arguments ?? {});
|
|
79
80
|
const t0 = Date.now();
|
|
80
|
-
|
|
81
|
+
// O client do handshake (clientInfo.name) viaja com a chamada: é o que
|
|
82
|
+
// deixa o backend dizer "MCP (Claude)" em vez de "MCP (agente)".
|
|
83
|
+
const result = await runWithClientHint(server.getClientVersion()?.name, () => callTool(name, args));
|
|
81
84
|
logStdioUsage({
|
|
82
85
|
tool: name,
|
|
83
86
|
action: typeof args.action === "string" ? args.action : null,
|
package/dist/registry.js
CHANGED
|
@@ -76,7 +76,7 @@ export const TOOLS = {
|
|
|
76
76
|
handler: repertorio,
|
|
77
77
|
},
|
|
78
78
|
sapiens_gallery: {
|
|
79
|
-
description: "Browse, publicação e upload das imagens do user. Sub-actions: list (últimas N peças, com prompt/model/url + isPublic + acervoUrl; kind=image por default, kind=video traz os takes com posterUrl, a capa, e kind=all mistura. Depois de ingerir ou gerar vídeo, é o list kind=video que dá a capa pra mostrar miniatura + player em vez de link solto), get (1 imagem com metadados, opcionalmente base64), publish (torna a PRÓPRIA imagem pública: entra na galeria pública + feed Pinterest, e ganha página indexável /imagem/<id> se o modelo não for degen devolve publicPageUrl; publicado NÃO despublica, não existe unpublish), upload (traz pra galeria uma peça gerada FORA da casa, por sourceUrl https, filePath local ou base64; ADMIN por ora), ingest (peça que a
|
|
79
|
+
description: "Browse, publicação e upload das imagens do user. Sub-actions: list (últimas N peças, com prompt/model/url + isPublic + acervoUrl; kind=image por default, kind=video traz os takes com posterUrl, a capa, e kind=all mistura. Depois de ingerir ou gerar vídeo, é o list kind=video que dá a capa pra mostrar miniatura + player em vez de link solto), get (1 imagem com metadados, opcionalmente base64), publish (torna a PRÓPRIA imagem pública: entra na galeria pública + feed Pinterest, e ganha página indexável /imagem/<id> se o modelo não for degen devolve publicPageUrl; publicado NÃO despublica, não existe unpublish), upload (traz pra galeria uma peça gerada FORA da casa, por sourceUrl https, filePath local ou base64; ADMIN por ora), ingest (A PONTE, qualquer conta logada: peça que a PESSOA dirigiu com a ficha do personagem daqui e um motor de fora renderizou, no crédito dela (Kie, fal, Magnific, Sogni, a própria placa): entra NATIVA na timeline e nasce PRIVADA (isPublic false), publicável DEPOIS por ela como qualquer geração da casa, nunca sozinha, com custo 0 em Sinapses e o custo real guardado em externalCost; carrega o prompt VERBATIM, as referências da casa (referenceImageIds), o personagem (characterId) e o motor (externalEngine 'provedor-motor'); a peça chega por filePath (local, só no MCP instalado), sourceUrl (o render, baixado pelo MCP na máquina da pessoa, nunca pelo servidor) ou base64; o carimbo +18 nasce na entrada pela CLASSE do provedor (peso aberto como Sogni, Replicate, HF, fal, WaveSpeed, Civitai e a própria placa entram carimbados, só a curadoria tira; serviço com moderação como Kie, Magnific, Freepik, Krea entra sem) ou por unfiltered=true dito pela pessoa (carimbo dela, ela tira depois); idempotente por sourceUrl ou pelo hash do arquivo, então re-ingerir corrige a ficha em vez de duplicar; tetos 12 MB imagem, 60 MB vídeo, 40 por dia. A chave do provedor NUNCA passa por aqui: quem gerou foi o harness da pessoa. Passo a passo na skill 'pontes'), refs (acopla peças da casa como REFERÊNCIA numa peça que já existe, de upload ou de ingest; ADMIN, e só na própria peça), cast (QUEM ESTÁ EM CENA numa peça que já existe, quando é mais de uma criatura: characterIds em ordem, o protagonista primeiro, e a peça passa a contar na ficha de todos eles em vez de sumir da segunda; qualquer membro, na própria peça). Use list/get pra reusar imagem como referência (passe o imageId em sapiens_image mode=edit ou mode=variation) ou pra mostrar pro user o que ele já tem; publish quando o user quer divulgar a imagem dele. IMPORTANTE sobre upload: a peça entra PRIVADA e continua privada: o servidor recusa publicar e recusa compartilhar na comunidade, porque ela é do usuário e a responsabilidade é dele. O caminho pra ela virar coisa pública é usar como referência numa geração daqui e publicar o resultado. O refs escreve só proveniência (de que ficha/folha a peça nasceu): não promove nada, e um upload continua sem publicar depois de ganhar referência. COMO A PEÇA FOI FEITA: list aceita skill=<slug> e devolve só o que saiu daquela skill (o mesmo slug que sapiens_image gravou em skills), com a busca varrendo as últimas 200 peças antes de cortar no limit; cada item volta com o campo skills, e null ali quer dizer que ninguém marcou, não que foi feita sem skill.",
|
|
80
80
|
schema: gallerySchema,
|
|
81
81
|
handler: gallery,
|
|
82
82
|
},
|
|
@@ -243,13 +243,14 @@ O passo a passo travado de cada fluxo mora na tool sapiens_skill, servida por es
|
|
|
243
243
|
- sapiens_skill action=list -> o índice (slug + quando usar cada uma).
|
|
244
244
|
- sapiens_skill action=get name=<slug> -> a skill inteira.
|
|
245
245
|
Slugs: ${skillMenuLine()}
|
|
246
|
-
Puxe a skill ANTES de: gerar música ou efeito sonoro (musica), escolher modelo de vídeo (video), gerar imagem (imagem), criar personagem ou prompt de Midjourney (personagem), criar na identidade do usuário (studio), postar no Fórum ou no chat (forum-comunidade), redigir qualquer texto publicável (voz-da-casa), incorporar o Sintético (companhia), missão de Desafio (trilhas), montar currículo (curriculo), guardar obra ou ferramenta no acervo (repertorio). Perdido no começo: primeiros-passos.
|
|
246
|
+
Puxe a skill ANTES de: gerar música ou efeito sonoro (musica), escolher modelo de vídeo (video), gerar imagem (imagem), criar personagem ou prompt de Midjourney (personagem), criar na identidade do usuário (studio), postar no Fórum ou no chat (forum-comunidade), redigir qualquer texto publicável (voz-da-casa), incorporar o Sintético (companhia), missão de Desafio (trilhas), montar currículo (curriculo), guardar obra ou ferramenta no acervo (repertorio), gerar em motor de fora com crédito próprio e trazer a peça pro acervo (pontes). Perdido no começo: primeiros-passos.
|
|
247
247
|
Cliente que lê resources MCP acha o MESMO conteúdo em skill://sapiens/<slug>/SKILL.md.
|
|
248
248
|
|
|
249
249
|
REGRA DE OURO:
|
|
250
250
|
- PRIMEIRO CONTATO ou "o que você faz?"/"como começo?"/"o que dá pra fazer?": chame sapiens_meta action=start e MOSTRE o resultado na sua voz. Sem login, ele ensina a conectar; logado, traz saldo + primeiros poderes com exemplos. É a porta de entrada: não despeje a lista inteira de tools, deixe o start guiar.
|
|
251
251
|
- LOGO APÓS UM LOGIN BEM-SUCEDIDO (action=login retornou ok): chame action=start na sequência e mostre a porta de entrada. O recém-chegado não sabe o que pedir; não o deixe na tela em branco, guie a primeira jogada sem ele precisar perguntar.
|
|
252
252
|
- Antes de gerar algo caro (imagem/música/vídeo), cheque saldo: sapiens_meta action=credits (ou subscription). Saldo baixo, avise o usuário antes. Vídeo é o mais caro da casa: confirme com ele antes de disparar.
|
|
253
|
+
- SEM SALDO, ou motor que a casa não tem: a casa é ponte, não muro. Se a pessoa tem crédito na Kie, na fal, na Magnific, na Sogni ou na própria placa, ela gera LÁ com a ficha do personagem daqui (sapiens_character action=get) e a peça volta pro acervo por sapiens_gallery action=ingest. A chave dela NUNCA passa pelo Sapiens (nem em arg, nem no chat). sapiens_meta action=pontes diz o que ela já tem configurado; skill 'pontes' tem o passo a passo.
|
|
253
254
|
- REGRA DO TIMEOUT: geração SÍNCRONA pode estourar o teto de ~120s do cliente e voltar 'Timeout' MESMO tendo gerado e COBRADO. Nunca repita às cegas: confira antes onde o resultado cairia (a tabela de onde conferir está na skill 'primeiros-passos').
|
|
254
255
|
- "sessionToken expirado" = refaça login: sapiens_meta action=login com o código de sapiensinteticos.com/conectar-claude.
|
|
255
256
|
- sapiens_meta action=formats devolve os schemas por formato; action=whoami diz tier (user/admin) + saldo. sapiens_image e sapiens_video action=models trazem o catálogo com o preço ATUAL: consulte em vez de chutar custo.
|
package/dist/remote.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createMcpHandler, withMcpAuth } from "mcp-handler";
|
|
2
|
+
import { runWithClientHint } from "./clientIdentity.js";
|
|
2
3
|
import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
3
4
|
import { buildToolList, callTool, SAPIENS_INSTRUCTIONS, } from "./registry.js";
|
|
4
5
|
import { getPrompt, listPrompts } from "./prompts.js";
|
|
@@ -263,7 +264,10 @@ export function createSapiensRemoteHandler(opts) {
|
|
|
263
264
|
if (!tokenLimiter.hit(token)) {
|
|
264
265
|
return tooMany("Muitas requisições dessa sessão num minuto. Espere um pouco e tente de novo.");
|
|
265
266
|
}
|
|
266
|
-
|
|
267
|
+
// Stateless: o initialize não fica entre requests, então a pista do client
|
|
268
|
+
// aqui é o User-Agent (prefixo "ua:" avisa o backend que é pista, não nome).
|
|
269
|
+
const ua = req.headers.get("user-agent") ?? undefined;
|
|
270
|
+
return runWithSessionToken(token, () => runWithClientHint(ua ? `ua:${ua}` : undefined, () => base(req)));
|
|
267
271
|
};
|
|
268
272
|
const authed = withMcpAuth(inner, (_req, bearerToken) => {
|
|
269
273
|
// Shape-check só (sem rede): a validação de VERDADE acontece por request
|
|
@@ -36,111 +36,111 @@ export function generateCoverLetterHTML(cover, resume, options = {}) {
|
|
|
36
36
|
const paragraphs = (cover.paragraphs || [])
|
|
37
37
|
.map((p) => `<p class="body-p">${escapeHtml(p)}</p>`)
|
|
38
38
|
.join("\n");
|
|
39
|
-
return `<!DOCTYPE html>
|
|
40
|
-
<html lang="${lang === "en" ? "en" : "pt-BR"}">
|
|
41
|
-
<head>
|
|
42
|
-
<meta charset="UTF-8">
|
|
43
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
44
|
-
<title>${escapeHtml(resume.name)} · ${title}</title>
|
|
45
|
-
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
46
|
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
47
|
-
<link href="https://fonts.googleapis.com/css2?${spec.fontsQuery}&display=swap" rel="stylesheet">
|
|
48
|
-
<style>
|
|
49
|
-
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
50
|
-
html { font-size: 15.5px; -webkit-font-smoothing: antialiased; }
|
|
51
|
-
body {
|
|
52
|
-
font-family: ${spec.bodyFamily};
|
|
53
|
-
color: #1B1E23;
|
|
54
|
-
background: ${spec.pageBg};
|
|
55
|
-
line-height: 1.6;
|
|
56
|
-
padding: 2.2rem 1rem;
|
|
57
|
-
}
|
|
58
|
-
.sheet {
|
|
59
|
-
max-width: 760px;
|
|
60
|
-
margin: 0 auto;
|
|
61
|
-
background: #fff;
|
|
62
|
-
border-radius: 12px;
|
|
63
|
-
overflow: hidden;
|
|
64
|
-
box-shadow: 0 1px 2px rgba(20,22,27,.05), 0 14px 44px rgba(20,22,27,.09);
|
|
65
|
-
padding: 3rem 3.2rem 2.6rem;
|
|
66
|
-
position: relative;
|
|
67
|
-
}
|
|
68
|
-
.sheet::before {
|
|
69
|
-
content: '';
|
|
70
|
-
position: absolute;
|
|
71
|
-
top: 0; left: 0; right: 0;
|
|
72
|
-
height: 3px;
|
|
73
|
-
background: ${spec.accent};
|
|
74
|
-
}
|
|
75
|
-
.head { border-bottom: 1px solid #E5E6E8; padding-bottom: 1.2rem; margin-bottom: 1.6rem; }
|
|
76
|
-
.head-name {
|
|
77
|
-
font-family: ${spec.displayFamily};
|
|
78
|
-
font-size: 1.7rem;
|
|
79
|
-
font-weight: 700;
|
|
80
|
-
letter-spacing: -0.02em;
|
|
81
|
-
margin-bottom: .15rem;
|
|
82
|
-
}
|
|
83
|
-
.head-title {
|
|
84
|
-
font-size: .78rem;
|
|
85
|
-
font-weight: 700;
|
|
86
|
-
text-transform: uppercase;
|
|
87
|
-
letter-spacing: .14em;
|
|
88
|
-
color: ${spec.accent};
|
|
89
|
-
margin-bottom: .55rem;
|
|
90
|
-
}
|
|
91
|
-
.head-contact { font-size: .76rem; color: #82868E; }
|
|
92
|
-
.doc-label {
|
|
93
|
-
font-size: .68rem;
|
|
94
|
-
font-weight: 700;
|
|
95
|
-
text-transform: uppercase;
|
|
96
|
-
letter-spacing: .16em;
|
|
97
|
-
color: #82868E;
|
|
98
|
-
display: flex;
|
|
99
|
-
align-items: center;
|
|
100
|
-
gap: .65rem;
|
|
101
|
-
margin-bottom: 1.2rem;
|
|
102
|
-
}
|
|
103
|
-
.doc-label::before { content: ''; width: 22px; height: 3px; border-radius: 2px; background: ${spec.accent}; }
|
|
104
|
-
.date-line { font-size: .8rem; color: #82868E; margin-bottom: 1.4rem; }
|
|
105
|
-
.subject { font-size: .92rem; font-weight: 700; margin-bottom: 1.2rem; }
|
|
106
|
-
.greeting { font-size: .9rem; margin-bottom: 1rem; }
|
|
107
|
-
.body-p { font-size: .88rem; color: #4A4E57; line-height: 1.8; margin-bottom: 1rem; text-align: justify; }
|
|
108
|
-
.closing { margin-top: 1.6rem; font-size: .9rem; }
|
|
109
|
-
.signature {
|
|
110
|
-
margin-top: .4rem;
|
|
111
|
-
font-family: ${spec.displayFamily};
|
|
112
|
-
font-size: 1.05rem;
|
|
113
|
-
font-weight: 700;
|
|
114
|
-
}
|
|
115
|
-
@media (max-width: 640px) {
|
|
116
|
-
body { padding: .5rem; }
|
|
117
|
-
.sheet { border-radius: 0; padding: 1.8rem 1.4rem; }
|
|
118
|
-
}
|
|
119
|
-
@media print {
|
|
120
|
-
@page { margin: 0.7in; size: A4; }
|
|
121
|
-
html { font-size: 13px; }
|
|
122
|
-
body { padding: 0; background: white; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
123
|
-
.sheet { max-width: 100%; border-radius: 0; box-shadow: none; padding: 0; }
|
|
124
|
-
.sheet::before { display: none; }
|
|
125
|
-
.body-p { break-inside: avoid; }
|
|
126
|
-
}
|
|
127
|
-
</style>
|
|
128
|
-
</head>
|
|
129
|
-
<body>
|
|
130
|
-
<div class="sheet">
|
|
131
|
-
<header class="head">
|
|
132
|
-
<h1 class="head-name">${escapeHtml(resume.name)}</h1>
|
|
133
|
-
${resume.title ? `<p class="head-title">${escapeHtml(resume.title)}</p>` : ""}
|
|
134
|
-
<p class="head-contact">${contactLine(resume)}</p>
|
|
135
|
-
</header>
|
|
136
|
-
<p class="doc-label">${title}</p>
|
|
137
|
-
${cover.dateLine ? `<p class="date-line">${escapeHtml(cover.dateLine)}</p>` : ""}
|
|
138
|
-
${cover.subject ? `<p class="subject">${escapeHtml(cover.subject)}</p>` : ""}
|
|
139
|
-
<p class="greeting">${escapeHtml(cover.greeting)}</p>
|
|
140
|
-
${paragraphs}
|
|
141
|
-
<p class="closing">${escapeHtml(cover.closing)}</p>
|
|
142
|
-
<p class="signature">${escapeHtml(resume.name)}</p>
|
|
143
|
-
</div>
|
|
144
|
-
</body>
|
|
39
|
+
return `<!DOCTYPE html>
|
|
40
|
+
<html lang="${lang === "en" ? "en" : "pt-BR"}">
|
|
41
|
+
<head>
|
|
42
|
+
<meta charset="UTF-8">
|
|
43
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
44
|
+
<title>${escapeHtml(resume.name)} · ${title}</title>
|
|
45
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
46
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
47
|
+
<link href="https://fonts.googleapis.com/css2?${spec.fontsQuery}&display=swap" rel="stylesheet">
|
|
48
|
+
<style>
|
|
49
|
+
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
50
|
+
html { font-size: 15.5px; -webkit-font-smoothing: antialiased; }
|
|
51
|
+
body {
|
|
52
|
+
font-family: ${spec.bodyFamily};
|
|
53
|
+
color: #1B1E23;
|
|
54
|
+
background: ${spec.pageBg};
|
|
55
|
+
line-height: 1.6;
|
|
56
|
+
padding: 2.2rem 1rem;
|
|
57
|
+
}
|
|
58
|
+
.sheet {
|
|
59
|
+
max-width: 760px;
|
|
60
|
+
margin: 0 auto;
|
|
61
|
+
background: #fff;
|
|
62
|
+
border-radius: 12px;
|
|
63
|
+
overflow: hidden;
|
|
64
|
+
box-shadow: 0 1px 2px rgba(20,22,27,.05), 0 14px 44px rgba(20,22,27,.09);
|
|
65
|
+
padding: 3rem 3.2rem 2.6rem;
|
|
66
|
+
position: relative;
|
|
67
|
+
}
|
|
68
|
+
.sheet::before {
|
|
69
|
+
content: '';
|
|
70
|
+
position: absolute;
|
|
71
|
+
top: 0; left: 0; right: 0;
|
|
72
|
+
height: 3px;
|
|
73
|
+
background: ${spec.accent};
|
|
74
|
+
}
|
|
75
|
+
.head { border-bottom: 1px solid #E5E6E8; padding-bottom: 1.2rem; margin-bottom: 1.6rem; }
|
|
76
|
+
.head-name {
|
|
77
|
+
font-family: ${spec.displayFamily};
|
|
78
|
+
font-size: 1.7rem;
|
|
79
|
+
font-weight: 700;
|
|
80
|
+
letter-spacing: -0.02em;
|
|
81
|
+
margin-bottom: .15rem;
|
|
82
|
+
}
|
|
83
|
+
.head-title {
|
|
84
|
+
font-size: .78rem;
|
|
85
|
+
font-weight: 700;
|
|
86
|
+
text-transform: uppercase;
|
|
87
|
+
letter-spacing: .14em;
|
|
88
|
+
color: ${spec.accent};
|
|
89
|
+
margin-bottom: .55rem;
|
|
90
|
+
}
|
|
91
|
+
.head-contact { font-size: .76rem; color: #82868E; }
|
|
92
|
+
.doc-label {
|
|
93
|
+
font-size: .68rem;
|
|
94
|
+
font-weight: 700;
|
|
95
|
+
text-transform: uppercase;
|
|
96
|
+
letter-spacing: .16em;
|
|
97
|
+
color: #82868E;
|
|
98
|
+
display: flex;
|
|
99
|
+
align-items: center;
|
|
100
|
+
gap: .65rem;
|
|
101
|
+
margin-bottom: 1.2rem;
|
|
102
|
+
}
|
|
103
|
+
.doc-label::before { content: ''; width: 22px; height: 3px; border-radius: 2px; background: ${spec.accent}; }
|
|
104
|
+
.date-line { font-size: .8rem; color: #82868E; margin-bottom: 1.4rem; }
|
|
105
|
+
.subject { font-size: .92rem; font-weight: 700; margin-bottom: 1.2rem; }
|
|
106
|
+
.greeting { font-size: .9rem; margin-bottom: 1rem; }
|
|
107
|
+
.body-p { font-size: .88rem; color: #4A4E57; line-height: 1.8; margin-bottom: 1rem; text-align: justify; }
|
|
108
|
+
.closing { margin-top: 1.6rem; font-size: .9rem; }
|
|
109
|
+
.signature {
|
|
110
|
+
margin-top: .4rem;
|
|
111
|
+
font-family: ${spec.displayFamily};
|
|
112
|
+
font-size: 1.05rem;
|
|
113
|
+
font-weight: 700;
|
|
114
|
+
}
|
|
115
|
+
@media (max-width: 640px) {
|
|
116
|
+
body { padding: .5rem; }
|
|
117
|
+
.sheet { border-radius: 0; padding: 1.8rem 1.4rem; }
|
|
118
|
+
}
|
|
119
|
+
@media print {
|
|
120
|
+
@page { margin: 0.7in; size: A4; }
|
|
121
|
+
html { font-size: 13px; }
|
|
122
|
+
body { padding: 0; background: white; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
123
|
+
.sheet { max-width: 100%; border-radius: 0; box-shadow: none; padding: 0; }
|
|
124
|
+
.sheet::before { display: none; }
|
|
125
|
+
.body-p { break-inside: avoid; }
|
|
126
|
+
}
|
|
127
|
+
</style>
|
|
128
|
+
</head>
|
|
129
|
+
<body>
|
|
130
|
+
<div class="sheet">
|
|
131
|
+
<header class="head">
|
|
132
|
+
<h1 class="head-name">${escapeHtml(resume.name)}</h1>
|
|
133
|
+
${resume.title ? `<p class="head-title">${escapeHtml(resume.title)}</p>` : ""}
|
|
134
|
+
<p class="head-contact">${contactLine(resume)}</p>
|
|
135
|
+
</header>
|
|
136
|
+
<p class="doc-label">${title}</p>
|
|
137
|
+
${cover.dateLine ? `<p class="date-line">${escapeHtml(cover.dateLine)}</p>` : ""}
|
|
138
|
+
${cover.subject ? `<p class="subject">${escapeHtml(cover.subject)}</p>` : ""}
|
|
139
|
+
<p class="greeting">${escapeHtml(cover.greeting)}</p>
|
|
140
|
+
${paragraphs}
|
|
141
|
+
<p class="closing">${escapeHtml(cover.closing)}</p>
|
|
142
|
+
<p class="signature">${escapeHtml(resume.name)}</p>
|
|
143
|
+
</div>
|
|
144
|
+
</body>
|
|
145
145
|
</html>`;
|
|
146
146
|
}
|