feat: dashboard Second Brain, essai 7 jours et vérification e-mail
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m14s
CI / Deploy production (on server) (push) Successful in 1m25s

Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-08-30 07:19:36 +00:00
parent 69c99e4f4f
commit 80ccc1f6de
95 changed files with 4158 additions and 618 deletions

View File

@@ -8,7 +8,7 @@ export const INTERACTIVE_PAGE_CAPS = {
maxDemosPerPage: 5,
maxSimsPerPage: 3,
maxOverviewCards: 4,
minOverviewCards: 2,
minOverviewCards: 3,
maxStatsItems: 5,
minStatsItems: 2,
maxJsonBytes: 128 * 1024,
@@ -30,8 +30,14 @@ export const PAGE_BLOCK_TYPES = [
'table',
'image',
'sim',
'steps',
] as const
export const STEPS_CAPS = {
minSteps: 2,
maxSteps: 12,
} as const
export const CALLOUT_KINDS = [
'definition',
'warning',
@@ -69,6 +75,8 @@ export const PAGE_HUMAN_STRING_KEYS = [
'intro',
'xLabel',
'yLabel',
// steps blocks
'rule',
// inherited from demos (speak etc. scanned via demo validator)
'speak',
'text',

View File

@@ -63,6 +63,33 @@
"simId": "ts-diagram"
},
"caption": "Le même cycle sur le diagramme Ts : les aires sont les chaleurs échangées."
},
{
"type": "steps",
"title": "Le COP frigorifique, dérivé pas à pas",
"steps": [
{
"tex": "\\eta_{\\text{Carnot}} = 1 - \\frac{T_c}{T_h}",
"rule": "Point de départ — rendement de Carnot",
"speak": "Le rendement maximal d'un moteur entre $T_h$ et $T_c$ ne dépend que des températures."
},
{
"tex": "\\mathrm{COP}_{\\text{PAC}} = \\frac{1}{\\eta_{\\text{Carnot}}} = \\frac{T_h}{T_h - T_c}",
"rule": "Inversion — pompe à chaleur",
"speak": "La pompe à chaleur est l'inverse du moteur : son COP est l'inverse du rendement."
},
{
"tex": "\\mathrm{COP}_{\\text{frigo}} = \\mathrm{COP}_{\\text{PAC}} - 1 = \\frac{T_c}{T_h - T_c}",
"rule": "Soustraction de 1 — réfrigérateur",
"speak": "Le frigo ne compte que la chaleur utile $Q_c$ : on retire 1 au COP de la PAC."
},
{
"tex": "\\mathrm{COP}_{\\text{frigo}} = \\frac{260}{300 - 260} = 6{,}5",
"rule": "Application numérique",
"speak": "Avec $T_c = 260$ K et $T_h = 300$ K : le COP maximal vaut 6,5."
}
],
"caption": "Chaque ligne découle de la précédente — la règle appliquée est en marge."
}
]
},

View File

@@ -4,6 +4,7 @@ export {
PAGE_BLOCK_TYPES,
CALLOUT_KINDS,
PAGE_HUMAN_STRING_KEYS,
STEPS_CAPS,
isPageHumanStringKey,
} from './constants'
export { pageSpecV1Schema, pageBlockSchema } from './schema'
@@ -22,4 +23,6 @@ export type {
CatalogSimRef,
GenericFormulaSim,
SimBlock,
StepsBlock,
DerivationStep,
} from './types'

View File

@@ -189,6 +189,31 @@ function normalizeBlock(
return out
}
if (type === 'steps' || type === 'derivation' || type === 'walkthrough' || type === 'solution' || type === 'proof') {
const rawSteps = Array.isArray(obj.steps) ? obj.steps : []
const steps = rawSteps
.map((st) => {
const r = asRecord(st)
if (!r) return null
const tex = asString(r.tex) || asString(r.latex) || asString(r.equation) || asString(r.math)
if (!tex) return null
const out: Record<string, unknown> = { tex }
const rule = asString(r.rule) || asString(r.transform) || asString(r.action) || asString(r.operation)
const speak = asString(r.speak) || asString(r.note) || asString(r.comment)
if (rule) out.rule = rule
if (speak) out.speak = speak
return out
})
.filter(Boolean)
if (steps.length < 2) return null
const out: Record<string, unknown> = { type: 'steps', steps }
const title = asString(obj.title)
if (title) out.title = title
const caption = asString(obj.caption)
if (caption) out.caption = caption
return out
}
return null
}

View File

@@ -7,7 +7,9 @@ import {
INTERACTIVE_PAGE_SCHEMA_VERSION,
PAGE_BLOCK_TYPES,
SIM_CAPS,
STEPS_CAPS,
} from './constants'
import { isValidSimParamId } from './sim-eval'
const intentSchema = z.enum(INTENT_IDS).optional()
@@ -90,6 +92,9 @@ const imageBlock = z.object({
const simParamIdSchema = z
.string()
.regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'Invalid sim identifier')
.refine(isValidSimParamId, {
message: 'Identifier collides with a reserved constant/function',
})
const genericSimParamSchema = z.object({
id: simParamIdSchema,
@@ -150,6 +155,22 @@ const simBlock = z.object({
caption: z.string().optional(),
})
const stepsBlock = z.object({
type: z.literal('steps'),
title: z.string().optional(),
steps: z
.array(
z.object({
tex: z.string().min(1),
rule: z.string().optional(),
speak: z.string().optional(),
})
)
.min(STEPS_CAPS.minSteps)
.max(STEPS_CAPS.maxSteps),
caption: z.string().optional(),
})
export const pageBlockSchema = z.discriminatedUnion('type', [
proseBlock,
formulaBlock,
@@ -160,6 +181,7 @@ export const pageBlockSchema = z.discriminatedUnion('type', [
tableBlock,
imageBlock,
simBlock,
stepsBlock,
])
const sectionSchema = z.object({
@@ -199,7 +221,7 @@ export const pageSpecV1Schema = z.object({
overview: overviewSchema.optional(),
sections: z
.array(sectionSchema)
.min(1)
.min(2)
.max(INTERACTIVE_PAGE_CAPS.maxSections),
footer: z.string().optional(),
})

View File

@@ -115,6 +115,27 @@ export type SimBlock = {
caption?: string
}
/**
* Step-by-step derivation (Symbolab/Khan style): equation states revealed
* line by line with the transformation rule used at each step. Fully
* generic — the LLM writes KaTeX + rules, the app renders; no drawing.
*/
export type DerivationStep = {
/** KaTeX of the equation state at this step. */
tex: string
/** Transformation rule applied to reach this state (e.g. "on sépare les variables"). */
rule?: string
/** Narration for the Play/Step player (falls back to rule). */
speak?: string
}
export type StepsBlock = {
type: 'steps'
title?: string
steps: DerivationStep[]
caption?: string
}
export type PageBlock =
| ProseBlock
| FormulaBlock
@@ -125,6 +146,7 @@ export type PageBlock =
| TableBlock
| ImageBlock
| SimBlock
| StepsBlock
export type PageSection = {
id: string

View File

@@ -6,7 +6,7 @@ import {
isPageHumanStringKey,
} from './constants'
import { pageSpecV1Schema } from './schema'
import { validateSimExprRefs } from './sim-eval'
import { validateSimExprRefs, isValidSimParamId } from './sim-eval'
import type {
PageBlock,
PageSpecV1,
@@ -79,6 +79,13 @@ function validateSim(
if (sim.simId === 'generic-formula') {
const generic = sim as Extract<typeof sim, { simId: 'generic-formula' }>
const paramIds = new Set(generic.params.map((p) => p.id))
if (paramIds.size !== generic.params.length) {
out.push(issue('sim_duplicate_id', `${path}.params`, 'Duplicate param id'))
}
const computedIds = new Set(generic.computed.map((c) => c.id))
if (computedIds.size !== generic.computed.length) {
out.push(issue('sim_duplicate_id', `${path}.computed`, 'Duplicate computed id'))
}
for (const p of generic.params) {
if (p.min >= p.max) {
out.push(issue('sim_param_range', `${path}.params`, `Param "${p.id}": min >= max`))