mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
285 lines
9.3 KiB
TypeScript
285 lines
9.3 KiB
TypeScript
/**
|
||
* S13/S15 — the personalized-card models of the campaign site. Pure data so
|
||
* the home section, the `/cards` gallery and the canvas renderer share one
|
||
* catalog (no second editor, no CMS): each entry carries the master asset, the
|
||
* real output size in pixels and, for photo/team models, the transparent window
|
||
* the photo is framed and zoomed over (fully covered under the bounded default).
|
||
*
|
||
* Geometry comes from the master files (pixel-measured, keep in sync with
|
||
* `docs/plans/cards-personalizados-campanha-impl.md` and
|
||
* `docs/plans/cards-time-de-voce-impl.md`): the photo frames are 1000×1000 and
|
||
* 1000×1440 despite the `1080x` in the original file names.
|
||
*/
|
||
|
||
const CARD_MODEL_IDS = [
|
||
'eu-sou-solla',
|
||
'perfil-quadrado',
|
||
'perfil-retangular',
|
||
'time-de-voce',
|
||
'time-do-estadual',
|
||
'minha-colinha',
|
||
] as const
|
||
|
||
export type CardModelId = (typeof CARD_MODEL_IDS)[number]
|
||
type CardModelKind = 'name' | 'photo' | 'team' | 'colinha'
|
||
|
||
export type CardRect = {
|
||
x: number
|
||
y: number
|
||
width: number
|
||
height: number
|
||
}
|
||
|
||
/**
|
||
* S33 — the visitor-adjustment policy of a photo/team model. `bounded` (the
|
||
* omitted default) pins the photo offsets so the transparent window is always
|
||
* covered (S13/S14); `free` leaves the offsets untouched in both axes — the
|
||
* photo may leave the frame and reveal the art behind it — while the zoom keeps
|
||
* its `[1, 4]` range (human gate of 2026-09-24).
|
||
*/
|
||
export type CardPhotoPosition = 'bounded' | 'free'
|
||
|
||
export type CardModel = {
|
||
id: CardModelId
|
||
kind: CardModelKind
|
||
label: string
|
||
/**
|
||
* S38 — the one-line explanation of the model shown by its catalogue item on
|
||
* `/conteudos`, under the name. Copy of the model, never of the studio.
|
||
*/
|
||
description: string
|
||
/**
|
||
* S38 — the popular nicknames the public catalogue search must find the model
|
||
* by ("santinho", "foto de perfil", "colinha", "estadual"). Search data of the
|
||
* item, never rendered as the model name.
|
||
*/
|
||
aliases: readonly string[]
|
||
assetSrc: string
|
||
width: number
|
||
height: number
|
||
/** Photo and team models: the transparent window the photo is framed over. */
|
||
photoWindow?: CardRect
|
||
/** Photo/team models: `free` unlocks the position clamp during the adjustment. */
|
||
photoPosition?: CardPhotoPosition
|
||
/** Team model: the master front overlay drawn above the cutout photo. */
|
||
overlaySrc?: string
|
||
/**
|
||
* Gallery tile art: the filled example shown by the team models before a
|
||
* cutout is ready.
|
||
*/
|
||
previewSrc?: string
|
||
/**
|
||
* S30/S31 — model whose flow is picked per state deputy from the shared
|
||
* catalog: on the team model the composer swaps `assetSrc`/`overlaySrc` for
|
||
* the selected entry (the defaults are the illustrative JULIO pair); on the
|
||
* colinha it fills the estadual row. Both gate the flow on the selection.
|
||
*/
|
||
stateDeputyPicker?: boolean
|
||
/** Optional gallery badge (e.g. `NOVO`). */
|
||
badge?: string
|
||
}
|
||
|
||
export const CARD_MODELS: readonly CardModel[] = [
|
||
{
|
||
id: 'eu-sou-solla',
|
||
kind: 'name',
|
||
label: 'Card com seu nome',
|
||
description: 'Coloque seu nome no card oficial.',
|
||
aliases: ['nome'],
|
||
assetSrc: '/cards/name-card-base.jpg',
|
||
width: 1080,
|
||
height: 1440,
|
||
},
|
||
{
|
||
id: 'perfil-quadrado',
|
||
kind: 'photo',
|
||
label: 'Moldura quadrada',
|
||
description: 'Foto de perfil quadrada.',
|
||
aliases: ['foto de perfil'],
|
||
assetSrc: '/cards/photo-square-frame.png',
|
||
width: 1000,
|
||
height: 1000,
|
||
photoWindow: { x: 0, y: 0, width: 1000, height: 740 },
|
||
},
|
||
{
|
||
id: 'perfil-retangular',
|
||
kind: 'photo',
|
||
label: 'Moldura vertical',
|
||
description: 'Foto vertical para stories.',
|
||
aliases: ['foto de perfil'],
|
||
assetSrc: '/cards/photo-portrait-frame.png',
|
||
width: 1000,
|
||
height: 1440,
|
||
photoWindow: { x: 0, y: 0, width: 1000, height: 1044 },
|
||
},
|
||
{
|
||
id: 'time-de-voce',
|
||
kind: 'team',
|
||
label: 'Time de você',
|
||
description: 'Entre para o time com sua foto.',
|
||
aliases: ['santinho'],
|
||
assetSrc: '/cards/team-card-base.png',
|
||
overlaySrc: '/cards/team-card-front.png',
|
||
previewSrc: '/cards/team-card-example.jpg',
|
||
width: 1080,
|
||
height: 1440,
|
||
photoWindow: { x: 286, y: 439, width: 592, height: 577 },
|
||
photoPosition: 'free',
|
||
},
|
||
{
|
||
id: 'time-do-estadual',
|
||
kind: 'team',
|
||
label: 'Time do estadual',
|
||
description: 'Escolha o estadual e personalize.',
|
||
aliases: ['estadual'],
|
||
// S30 — the defaults are the illustrative pair (JULIO, the deputy of the
|
||
// approved example art); the composer swaps them for the selected entry of
|
||
// `stateDeputyCatalog`. `previewSrc` is the exact file approved by the
|
||
// human for the gallery tile and the pre-selection placeholder.
|
||
assetSrc: '/cards/estaduais/julio-fotos.webp',
|
||
overlaySrc: '/cards/estaduais/julio-base.webp',
|
||
previewSrc: '/cards/modelo-time-de-voce-com-estadual.jpeg',
|
||
stateDeputyPicker: true,
|
||
width: 1080,
|
||
height: 1440,
|
||
photoWindow: { x: 286, y: 439, width: 592, height: 577 },
|
||
photoPosition: 'free',
|
||
},
|
||
{
|
||
id: 'minha-colinha',
|
||
kind: 'colinha',
|
||
label: 'Minha colinha',
|
||
description: 'Monte sua cola de votação.',
|
||
aliases: ['santinho', 'colinha'],
|
||
// S34 — the approved art delivered by the human is the base of the preview
|
||
// and the download (drawn whole at 1.2×); the only drawn element is the
|
||
// estadual row overlay. The gallery tile shows the same art via `assetSrc`.
|
||
assetSrc: '/cards/modelo-colinha.jpeg',
|
||
stateDeputyPicker: true,
|
||
badge: 'NOVO',
|
||
width: 1080,
|
||
height: 1920,
|
||
},
|
||
]
|
||
|
||
/** Fields every name slot shares, whatever the drawing alignment. */
|
||
type CardNameSlotBase = {
|
||
capHeight: number
|
||
minCapHeight: number
|
||
maxInkWidth: number
|
||
maxLines: number
|
||
fill: string
|
||
}
|
||
|
||
/** Left-aligned slot with a two-line band (S13 name model). */
|
||
export type CardNameBlockSlot = CardNameSlotBase & {
|
||
align: 'left'
|
||
leftX: number
|
||
capTop: number
|
||
maxBlockBottom: number
|
||
}
|
||
|
||
/** Rotated rectangle + background the renderer draws for a banner. */
|
||
export type CardBanner = {
|
||
centerX: number
|
||
centerY: number
|
||
width: number
|
||
height: number
|
||
/** Model-pixel ceiling the width may grow to (S16); equals `width` when fixed. */
|
||
maxWidth: number
|
||
rotationDeg: number
|
||
background: string
|
||
}
|
||
|
||
/** Centered single-line slot drawn on a rotated banner (S15 team model). */
|
||
export type CardNameBannerSlot = CardNameSlotBase & {
|
||
align: 'center'
|
||
maxLines: 1
|
||
banner: CardBanner
|
||
}
|
||
|
||
export type CardNameSlot = CardNameBlockSlot | CardNameBannerSlot
|
||
|
||
/**
|
||
* Name model slot, pixel-measured from the filled master (`FULANO` example):
|
||
* the `#SOU` line and the visitor's name share the same left border at x=213
|
||
* (S14 — the name is left-aligned, not centered); the cap band tops at y=430
|
||
* with a 106px cap height and the master's ink spans x[214..925] (712px), so a
|
||
* fitted name never crosses 714px. The visitor's name is the only thing drawn
|
||
* over the base; the fixed lines already live in the art.
|
||
*/
|
||
export const NAME_CARD_SLOT = {
|
||
align: 'left',
|
||
leftX: 213,
|
||
capTop: 430,
|
||
capHeight: 106,
|
||
maxInkWidth: 714,
|
||
maxBlockBottom: 630,
|
||
minCapHeight: 48,
|
||
fill: '#ffec01',
|
||
maxLines: 2,
|
||
} as const satisfies CardNameBlockSlot
|
||
|
||
/**
|
||
* S15 — geometry pixel-measured from `team-card-example.jpg` (the reference
|
||
* filled card). Neither master carries the top banners, so the renderer draws
|
||
* both rectangles + text over the composed art: the red `TIME DE` label and the
|
||
* blue name banner. `rotationDeg` is counter-clockwise, matching the masters'
|
||
* built-in tilt (the photo window sits inside the sky area at x=286 y=439).
|
||
*/
|
||
export const TEAM_CARD_LABEL = {
|
||
text: 'TIME DE',
|
||
centerX: 545,
|
||
centerY: 162,
|
||
width: 693,
|
||
maxWidth: 693,
|
||
height: 118,
|
||
rotationDeg: -3.5,
|
||
background: '#e50e2f',
|
||
fill: '#ffec01',
|
||
capHeight: 93,
|
||
} as const
|
||
|
||
/**
|
||
* S16 — the blue name banner is dynamic: `width` is the S15 reference kept while
|
||
* the fitted ink fits the reference band (`width - padding` = 470) and
|
||
* `maxWidth` (1000) is the ceiling the banner grows to only as needed. The
|
||
* ceiling keeps the lowest rotated corner (`313 + 500·sin 4.1° + 88·cos 4.1°`
|
||
* ≈ 436.5 — conservative corner check) above the photo window top (y=439); it is
|
||
* also the pair that fixes the ink padding: `maxWidth - slot.maxInkWidth = 39`
|
||
* (= the S15 pair `509 - 470`).
|
||
*/
|
||
export const TEAM_CARD_NAME_BANNER = {
|
||
centerX: 545,
|
||
centerY: 313,
|
||
width: 509,
|
||
maxWidth: 1000,
|
||
height: 176,
|
||
rotationDeg: -4.1,
|
||
background: '#0061a5',
|
||
fill: '#ffffff',
|
||
} as const
|
||
|
||
/**
|
||
* S16 — the readable floor drops to a 40px cap (the human gate decision of
|
||
* 2026-09-19: long compound names fit and only clearly absurd names are asked
|
||
* to shorten) and the ink ceiling becomes the banner ceiling pair: 961 = 1000
|
||
* (banner) − 39 (padding), the same padding measured in the S15 pair 509 − 470.
|
||
*/
|
||
export const TEAM_CARD_NAME_SLOT = {
|
||
align: 'center',
|
||
banner: TEAM_CARD_NAME_BANNER,
|
||
capHeight: 130,
|
||
minCapHeight: 40,
|
||
maxInkWidth: 961,
|
||
fill: TEAM_CARD_NAME_BANNER.fill,
|
||
maxLines: 1,
|
||
} as const satisfies CardNameBannerSlot
|
||
|
||
export const BRAND_FALLBACK_FONT = 'sans-serif'
|
||
|
||
export const isCardModelId = (value: unknown): value is CardModelId =>
|
||
typeof value === 'string' && (CARD_MODEL_IDS as readonly string[]).includes(value)
|
||
|
||
export const getCardModel = (id: CardModelId): CardModel | undefined =>
|
||
CARD_MODELS.find((model) => model.id === id)
|