Files
fitme3070.pt/CLAUDE.md
T
andre e804c52b4e feat(site): Comunidade section with Instagram posts the gym was tagged in
Per-gym Graph API /tags fetch with file cache and local thumbnails, section after the gallery, navbar link and admin page (Gestão > Site).
2026-10-26 14:19:43 +00:00

27 KiB
Raw Blame History

fitme3070 — architecture

Backoffice + public site for a gym (ginásio) chain, plain PHP 8.4, no framework. This file exists because the pattern below was inherited from a CodeIgniter 4 port and had never been written down as a decision (audit 2026-09-24, item 6.1) — it describes what the codebase actually does, kept deliberately, not a design proposal for a rewrite.

Request lifecycle

Every page is its own controller-and-view combined. A page in views/admin/*.php or site/*.php does, in order: bootstrap (config/db.php + config/functions.php), an auth guard, read $_POST/$_GET and call model functions, then fall through into its own HTML. There is no separate template layer and no front controller — 71+ PHP files are directly reachable by URL (docroot is the repo root; .htaccess denies partials, shared/platform-tools/*.php, and *.md, but doesn't allowlist).

This is a known, accepted gap, not an oversight — see "What's not done" below. Introducing a real front controller/router for the app's own pages would touch most of views/, .htaccess's friendly-URL block, and every hardcoded base_url('views/...') call; it's a genuine rewrite, tracked separately, not bundled into this doc.

Data layer: config/functions_*.php as models

SQL lives in config/functions_*.php, one file per domain (functions_team.php, functions_galerias.php, functions_gyms.php, functions_memberships.php, …) plus functions_db.php for cross-cutting users queries. Functions are named db_<noun>_<verb>() (db_gyms_create, db_team_update, db_gallery_list). Views never write SQL directly — if you're adding a query, it goes in the matching functions_*.php, not inline in the page. (Audit 6.3, closed 2026-09-25 — grep -rl '\$pdo->\(query\|prepare\|exec\)' views/ should always return nothing.)

config/functions.php is the loader: every page requires config/db.php (opens $pdo, starts the session, connects to platform-tools) then config/functions.php (requires every functions_*.php in order). Read that file's own header comment before adding a new one — order matters for a few (auth.php before anything that calls require_login(), etc.).

Auth

Login/session/roles run on shared/platform-tools/functions_auth.php ('auth' => 'shared' in config/db.php, migrated 2026-09-25 — audit 1.3). config/auth.php keeps the app's own guard names (require_login(), require_admin(), require_staff(), require_sysadmin(), is_admin(), etc.) as a thin layer over the shared primitives, so none of the ~30 call sites in views/ had to change. Role ladder: sysadmin(100) > admin(80) > staff(40) > client(10, portal-only). "Manter sessão iniciada" is the shared auth_remember_* module.

The login-attempt throttle (config/functions_throttle.php) stays the app's own, deliberately not the shared one — see the comment in config/db.php for why (avoids double-counting the same form with two independent throttles). A throttled login feeds platform_ip_record_offence(), so repeat offenders get fleet-wide IP-banned.

Soft delete / trash

Deletes are soft (deleted_at/deleted_by) via trash_delete() in config/functions_trash.php. Gestão → Lixeira lists everything deleted across entity types and only allows permanent purge after 30 days (enforced in the DELETE's WHERE, not just hidden in the UI). Adding a new deletable entity means adding it to trash_types() in that file.

CSRF

csrf_check() (config/functions_csrf.php) refuses any non-POST request outright — state changes must be POST forms carrying csrf_field()'s token. There is no PRG (Post-Redirect-Get) convention beyond that; several admin pages still re-render the form directly on POST rather than redirecting (audit flags this as inconsistent, not yet unified — tracked separately, low priority).

Audit log

Models call audit_row() before a write and audit_change() after (diffs before/after automatically); ad-hoc events elsewhere use audit('entity.verb', [...]). Both come from shared/platform-tools via config/functions_audit.php. Read via Sistema → Auditoria (platform.php?tool=audit).

Multi-gym

Since 2026-09-25, one install serves several physical gyms: the gyms table, one URL folder per gym (/3070/, /<postal-code>/), content rows carry gym_id, and the backoffice has an "a editar" gym switcher (admin_gym_id() / admin_gym() in config/functions_gyms.php). / and /home 301 to the default gym (is_default); the other published gyms appear as highlighted buttons (.gym-switch-btn) at the right end of the public navbar (site/includes/navbar.php). site/gym_chooser.php is no longer used. Each gym has a brand colour (gyms.brand_color, #RRGGBB, migration migration_20261013_gym_brand_color.sql; gym 1 = green #C2D500, gym 2 = red): set in Gestão → Ginásios, emitted as :root overrides by gym_brand_css() in site/includes/include.php. New components must use --fitme-accent* / --fitme-on-accent, never a hard-coded colour; the CrossFit nav pill follows the brand colour too. The public navbar's bottom edge is an always-on, slow (14s) sweep in the brand colour (.header-section::before/::after, nav-sweep, static with reduced motion). On the home page and the CrossFit page ($nav_center_logo set in site/index.php / site/crossfit.php, shared by _open.php; the CrossFit video header no longer prints its own logo, only a visually-hidden h1) the header starts taller (desktop >= 992px) with a second, big copy of the logo centred (fixed 52px height, so tall logos like the CrossFit one clear the links) (.navbar-brand-hero, aria-hidden); once scrollY >= 40 (script in navbar.php toggles .is-top) the bar shrinks, the big logo fades out and the 150px left logo fades in (opacity crossfade, no movement). The hero gets padding-top: 120px on desktop so its event card clears the tall bar. On desktop the brand is absolute at the left and the links are centred on the full bar width; nav padding and logo width live in CSS, not inline. The other-gym buttons (.gym-switch) sit in the match head, a glimpse into the other site: painted in the first other gym's brand (navbar.php puts gym_brand_vars() of $other_gyms[0] on .gym-switch), a 5px strip in its accent on the slash, the face a shade darker (--fitme-accent-dark to 82% of it mixed with black) with a shadow inside the cut so the strip reads, and its logo in that gym's on-accent ink (brightness(0), plus invert(1) with .gym-switch--light-ink when the on-accent is white). Paint is shared; geometry per breakpoint: desktop = absolute block at the right end of the bar (full height, width follows its buttons), left side cut on the brand slash (22px); below 992px = a full-width band across the bottom of the open menu, top edge slashed (18px). 992-1199px: links shrink (.8125rem, --bs-navbar-nav-link-padding-x: .35rem) so the 3070 menu clears the logo and head. Menu links are .875rem bold. Below 992px the open menu fills the screen under the bar (absolute, 100dvh - var(--nav-h), --nav-h = the header's real height, set by the navbar.php script, links/buttons centred, opacity fade; not position:fixed because the header's backdrop-filter is its containing block). The bar is liquid glass in Chromium: backdrop-filter: url(#nav-glass) blur(6px) saturate(180%) (#nav-glass in include.php: blurred low-frequency feTurbulence -> feDisplacementMap, bends the page behind it), gated by @supports (-webkit-app-region: drag) (Chromium-only property); Safari/Firefox keep the plain blur. Brand slash template: the cut corners of the "in" box are one rule in style.css ("Brand slash (template)"): class="fx-slash" (both ends) or fx-slash-start (left only), size via --slash (default 4px); pseudo-elements are added to that rule's selector list. Used by the nav and section-title underlines and the CrossFit pill (--slash: 8px, no radius); never paste the polygon again. The hero text has no glow, only a tight 0 1px 2px shadow (h1, .hero-brand, .hero-tagline) and the hero frosted-band blur (.hero-section .container::before) is at opacity .8. The hero subtitle (.hero-text h1 span, gym hero_subtitle) is a block strapline at .42em of the h1, weight 600, tracked out. The hero event card's border is solid --ev-accent, the same colour as its button (button text stays --fitme-on-accent). Logos (.navbar-brand img, .gym-switch-logo) get the same #pt-noise grain filter as h1/h2, and .hero-event-go, .cta-btn, .btn-primary joined the --noise button list. Section-title underlines (.section-title.deco h2:after) use the same slashed 4px bar as the nav underline. The hero event button text uses --fitme-on-accent (white on red). The nav underline (.nav-link:after) is a 4px bar 3px under the text with slashed ends (clip-path, like the "in" box of the FitMe in logo). The hero event card (.hero-event) is square (no radius on card, badge or button), taller (88px min) and has a defocus gradient: ::after is a backdrop-filter: blur() layer masked left->right, sharp on the left and most blurred on the right; the card itself must not get filter/backdrop-filter/opacity/mask or the blur stops reaching the hero photo. Each gym can have a logo (same migration, plus migration_20261026_gym_logo.sql: the column was added to the 2026-10-13 file after it had already run on dev, so dev had no logo column until 2026-10-26 — never edit a migration that has run, add a new one); the header image and the logo are uploaded in Gestão → Ginásios (gyms_save_image() → uploads/gyms/, validated by content, SVG through svg_is_safe()), not typed as paths; db_gyms_create/update skip brand_color/logo while the migration isn't applied (_gyms_cols()); the navbar button for another gym is drawn in that gym's brand colour (gym_brand_vars() inline); with a logo (gym_logo(): the gym's own, or for the default gym the bundled logo_black.svg) the button shows the logo in full colour straight on the frosted navbar, with no face and no darkening on hover (.gym-switch-btn--logo: thin brand outline, hover = spinning ring only, no glow, a thin dark drop-shadow outline on the image for contrast (a white halo washed out the lime FitMe 3070 logo); name as alt), without one it shows the name. The navbar brand on each gym's page uses that gym's logo too. Uploads (header + logo) also accept JPEG XL (.jxl, detected by signature in upload_image_ext() when the caller allows 'jxl'; the gyms form and gallery photos (db_gallery_image_upload()) do; AddType image/jxl in .htaccess; GD can't resize it, so it is stored as uploaded; Chrome/Firefox may not display JXL, Safari does). The public gallery lists its folder by extension (site/includes/gallery.php, site/index.php: jpg/jpeg/png/gif/webp/jxl; before 2026-10-26 it only listed *.jpg, so PNG/WebP uploads never showed). Gallery photos are not in the scroll "hover" (.is-centered, main.js) any more; only a real hover lights them. Mensalidades (.pricing-section::before) has a slow (26s) drifting brand-colour glow over the metal texture (pricing-drift, static with reduced motion). An unpublished gym 404s publicly but previews for staff.

  • SEO per gym (2026-10-26): site/includes/include.php builds title/description per page from the gym ($gym_name, $loc_in = «na Zona …»/«em Mira»); gym 1 keeps hand-written texts, other gyms get generated ones (never the 3070 texts). The gym's own seo_title / seo_description (Gestão → Ginásios) override the homepage each on its own. Keywords, author, og:site_name, favicon (gym logo if SVG) and the JSON-LD logo (seo.php) follow the gym. FitMe in's SEO texts were set in the dev DB only; production needs them entered in Gestão → Ginásios.
  • site/crossfit.php decides the 404 for gyms without has_crossfit before _open.php, so it is a real 404 status.
  • Events per gym: Eventos form «Mostrar em» writes events.gym_id (NULL = all gyms; site_active_events() filters on it). site/evento.php renders the event in its gym's brand/name/info. The hero event button text uses --ev-on (white/black computed from the event colour), not the page gym's on-accent.
  • Navbar: the desktop bottom sweep line stops before the other-gym head via --switch-w (set by setNavH in navbar.php). The hero blur (.hero-section .container::before) has a second diagonal mask, so it is weaker at the top left. The «Marca uma aula experimental» link was removed; #trialModal stays (only reopened by ?agendar=erro).
  • The other-gym logo button is a light plate (92% white) with the logo in its own colours (2026-10-26; before: logo inked to the head's on-accent).
  • YourFit modalidades use their brand glyphs: fitme-yourfit-*.svg were traced (2026-10-26) from the left tile of the wordmarks uploads/modalidades/icons/yourfit-*.png (150×33, the only source found) into one even-odd currentColor path, so they keep the CSS colour/hover and the transform animations (no stroke draw).

Imagens / JPEG XL (2026-10-26)

Fleet transition to JPEG XL (root Y:\GitTea\CLAUDE.md, "Images"); fitme3070 is the reference site. Uploads already accept .jxl (gyms header/logo, gallery photos). The gallery edit page (views/admin/galerias/edit.php) takes several files at once (images[], one flash with the count plus each refused file and why, from db_gallery_image_upload(..., &$error); limits shown from gallery_upload_max_bytes() / max_file_uploads / post_max_size, and a POST over post_max_size gets its own message). A .jxl without fallback shows as a «JPEG XL» placeholder tile with its filename (Chrome can't draw it); with a fallback it uses img_tag().

  • Optimizer (config/functions_images.php, built 2026-10-26): img_optimize() hands .jxl to img_jxl_optimize(), which decodes with djxl (or Imagick if it lists JXL), resizes with GD, re-encodes a smaller .jxl with cjxl -d 1.0 when resized, and always writes the fallback foto.jxl.webp (.jxl.jpg if GD has no WebP) beside it. Idempotent (skips when the fallback is newer and within the cap). With no decoder it returns skipped («JPEG XL sem descodificador no servidor»), uploads never fail.
  • No decoder exists yet (checked 2026-10-26): the VPS fitme image (php:8.4-apache, Debian trixie) has GD only; the dev webdevops/php-apache:8.2 has Imagick without JXL. Both need libjxl-tools (cjxl/djxl) installed. 2026-10-26: added to the VPS /srv/stacks/fitme.andmore.pt/Dockerfile apt line (backup Dockerfile.bak-20261026), active only after docker compose build && docker compose up -d there (the user runs it); on dev it is installed by hand in the running container (apt-get install libjxl-tools as root) and is lost when the TrueNAS app recreates it. Until then .jxl uploads have no fallback and show only in browsers with JXL (Safari). After installing, press «Otimizar» on each gallery to backfill.
  • Serving: img_tag($rel, $attrs) (<picture><source type="image/jxl"><img src=fallback>, plain <img> otherwise), img_css_bg($rel) (hero: url(fallback) then image-set() with the JXL), img_compat_rel() (single-URL places: lightbox link, JSON-LD image, admin preview), img_list_dir() (gallery listings, hides *.jxl.*). Used in site/includes/gallery.php (lightbox shows the tile's currentSrc), site/index.php hero, include.php preload (type="image/jxl" for a JXL hero), seo.php, navbar logos. picture { display: contents } in style.css keeps the old img sizing.
  • Moves/deletes keep the pair together: img_fallback_follow($from, $to='') in trash (_trash_image_file), gyms_remove_image(), events_image_remove().
  • Still open: events/modalidades/team uploads don't accept .jxl yet. Every image input in the backoffice shows the current image next to it with a remove control (gyms form does; check events, modalidades, team, galerias, system settings).

The navbar only links sections the gym shows and has content for (site_section_has_content() in config/functions_site.php: Galeria = photos in its folder, Modalidades, Equipa = active members (site_team_active(), also used by the page), Horário, Mensalidades, Comunidade = cached posts). The gallery section background is a 7% brand-colour tint, not white.

Comunidade: Instagram posts the gym was tagged in (2026-10-26)

Section comunidade (after the gallery in site/index.php, navbar link «Comunidade», key in gym_sections()) shows the latest public Instagram posts where the gym's account was tagged, as a square grid linking to each post; hidden when there are none. config/functions_instagram.php mirrors the reviews module: Graph API GET /{ig-user-id}/tags (INSTAGRAM_GRAPH, v21.0), cache config/cache/instagram_<gid>.cache.php, lazy refresh under a non-blocking lock, old copy kept on error (retry after 1 h). Thumbnails (thumbnail_url for videos, media_url otherwise; only JPEGs from cdninstagram.com/fbcdn.net) are downloaded at refresh time to uploads/instagram/<gid>/<media id>.jpg (git-ignored) because Instagram CDN URLs expire; stale ones are deleted.

  • Per gym, no migration: system_settings keys instagram_user_id_<gid> and instagram_token_<gid> (encrypted with secret_encrypt; .env INSTAGRAM_TOKEN_<gid> wins); instagram_max (3–24, default 8) and instagram_cache_hours (default 6) are shared. Admin: Gestão → Site → «Comunidade (Instagram)» (views/admin/instagram/index.php, edits the gym in the switcher): token, account id, «Encontrar» (lists me/accounts Pages with their linked IG account), «Atualizar agora», status + preview.
  • Needs from Meta: an Instagram professional (business/creator) account linked to a Facebook Page, a Meta app, and a token with instagram_basic, instagram_manage_comments, pages_read_engagement (a Business Manager system-user token does not expire; a long-lived user token lasts 60 days). Private posts and some reels without media_url are skipped.
  • Gyms whose sections column is an explicit list (not NULL) don't get the new section until it is ticked in Ginásios → Secções. Not configured anywhere yet (2026-10-26): tested on dev with fake cached posts only.

platform-tools

shared/platform-tools is a git submodule (pinned branch = main in .gitmodules), not vendored — see PLATFORM_TOOLS.md for why it went vendored→submodule→ back, and don't repeat that mistake without reading it first. config/db.php wires it via platform_connect(): shared auth (above), fleet IP-ban guard (runs on every request), the sysadmin router (platform.php / api/platform.php — Backups, Actualizações, Auditoria, IP bans, Geo, Origem das visitas, all one front controller, just for those pages, not the app's own).

Deploy

Push to master → Gitea webhook → api/platform.php?endpoint=auto_update → the VPS pulls, syncs the submodule, applies pending database/migration_*.sql files, done in seconds. No staging branch; the VPS checkout is what's live. deploy.sh (on the VPS, not in this repo) is the manual fallback. See PLATFORM_TOOLS.md.

Dependencies

composer.json/composer.lock are real (composer install works, composer audit/outdated are meaningful) — added 2026-09-25, matching the fotografia platform's setup. vendor/PHPMailer/ is the one hand-vendored exception, gitignore- allowlisted: shared/platform-tools/functions_mail.php hard-codes that exact path fleet-wide, so Composer's own vendor/phpmailer/phpmailer/ exists in parallel but isn't what actually gets require_once'd. See ASSETS.md for frontend asset versions. Both the public site and the backoffice load Bootstrap 5.3.3 (unified 2026-09-25 — the public site ran 4.3.1 before that); jQuery is one shared copy, not duplicated.

What's not done (tracked, not silently missing)

The full audit and its live status live in a separate report, not in this repo (CLAUDE_GITEA/audits/fitme3070-auditoria-2026-09-24.html on the maintainer's machine). As of 2026-09-25: no real front controller (6.2, above), no central listing registry, no DataTables on backoffice listings, no srcset (deliberate — uploads are already close to their CSS display size, see the audit item), views/admin/galerias/ still has separate create/edit (deliberately — they're not actually a duplicated pair, see the audit item). None of these are urgent; none should be "fixed" as a side effect of an unrelated change — each is its own piece of work.

Dev test accounts (DEV ONLY)

In the development database there must always exist two logins for testing: sysadmin@mail.pt (role sysadmin) and admin@mail.pt (role admin), password 123. Ensure/repair them with php scripts/dev_accounts.php (CLI only, idempotent upsert with password_hash, active flags set). The script fails closed: it refuses unless .env has APP_ENV=dev AND DB_HOST is 192.168.1.3:3181 or localhost. It must never be run, deployed or adapted for production, and these accounts must never exist in a production database.

Brand feed (2026-10-09)

  • api/brand.php is a one-line stub to shared/platform-tools/api/brand.php (public JSON: name, logos, colours; README "Brand feed"). Declared in platform_connect(['brand' => ...]) in config/db.php; colours are the :root tokens (--fitme-accent #c2d500, --fitme-dark #0b0b0d, --fitme-accent-dark #a6b600). logo_light = light-coloured logo (logo_crossfit_white.svg), logo_dark = logo_crossfit.svg.
  • site_logo_rel() (config/functions.php) resolves logos for both the feed and load_email_template(): setting file in uploads/public/logo/ (convention), then legacy assets/img/logo/, then the static logo. views/sysadmin/system_settings.php now uploads logos to uploads/public/logo/ (dir created on demand, 0755; uploads/.htaccess blocks script execution); old files in assets/img/logo/ still resolve as fallback and in the settings preview. Brand block in config/db.php already prefers the setting file via site_logo_rel(), else the static SVGs.

Dev toolbar partilhada (2026-10-09)

  • DEV ONLY, do platform-tools (functions_dev.php, README "Dev tooling"; produção segue main, que não tem o ficheiro: tudo degrada para "não é dev").
  • Ficheiros: config/dev_guard.php (shim), includes/dev_toolbar.php (incluído no fim de includes/footer.php), dev_login.php (na RAIZ: o site não tem public/; .htaccess tem a regra ^dev_login/?$ com -f), scripts/dev_accounts.php (platform_dev_accounts_run).
  • Contas admin@mail.pt / sysadmin@mail.pt (pw 123). O login usa auth_login() + fit_stamp_login_ua() como o login real; CSRF do site = campo _csrf / $_SESSION['csrf_token'] (verificação sem exit).

Ambiente de dev: moldura e login de um clique (2026-10-13)

  • includes/env_ribbon.php (moldura às riscas + etiqueta DEV, só fora de APP_ENV=prod) incluído no <head> de includes/header.php, includes/auth_header.php e site/includes/include.php.
  • A barra de login de dev (includes/dev_toolbar.php) vai também em includes/auth_footer.php e site/includes/footer.php. Botões Cliente / Staff / Admin / Sysadmin (client@mail.pt e staff@mail.pt criados por scripts/dev_accounts.php; o painel do staff é views/staff/dashboard.php, criado em 2026-10-13).
  • Auditoria da sidebar: docs/audits/2026-10-13-fitme3070-sidebar.md (em Y:/GitTea; aplicada, ver «Barra lateral» abaixo).

Barra lateral (2026-10-13)

  • Padrão da frota (.claude/skills/sysadmin-navbar-audit). CSS todo em assets/css/backoffice.css (tokens --bo-*, casca, mini, view transitions; carregado com ?v=filemtime por _asset_versioned); já não há <style> inline no header.php. Prefixo bo- nas peças novas; .sidebar, .topbar, .nav-link mantêm-se.
  • Cabeçalho fixo [☰][marca][↗ site público] (↗ abre base_url('/') em nova aba, único sítio), utilizador logo abaixo (avatar com iniciais, nome, crachá de papel, menu Perfil / Terminar sessão). A topbar fica só com o seletor «A editar», o tema e, em telemóvel, o botão que abre a gaveta. Um só manipulador (data-sidebar-toggle, em includes/footer.php): desktop alterna mini (80px, localStorage bo-sidebar-mini), telemóvel abre o off-canvas.
  • Menu em includes/header.php com os auxiliares de includes/nav_helpers.php: bo_nav_link() (ativo por sufixo do SCRIPT_NAME, aria-current, title + aria-label), bo_nav_group_start/end() (grupo recolhível, data-short = 4 letras), bo_dashboard_path(). Ordem: Painel (admin: views/admin/dashboard.php; staff: views/staff/dashboard.php; cliente: views/user/dashboard.php), Gestão (subgrupos Site, Horários, Comercial, Legal), Utilizadores, Sistema por último. Cada ligação mantém o seu is_admin()/is_sysadmin().
  • Estado sem flash: script no <head> põe html.bo-mini e data-bo-groups; estado dos grupos em localStorage bo-nav-groups; scroll da lista em sessionStorage bo-nav-scroll. Grupo novo = chave nova na lista :is(...) de grupos fechados em backoffice.css.
  • Mini: categorias em texto (data-short), nunca ícones. Ícones: só Bootstrap Icons de contorno.
  • includes/navbar.php (barra antiga) foi apagado; system_settings.php já não o inclui.
  • views/staff/dashboard.php: saudação mínima (o staff ainda não alcança nada em views/admin/).
  • Gotcha de dev: a tabela trial_leads não existe na BD de dev, por isso views/admin/trials/index.php dá 500 (migração por aplicar; anterior a esta mudança).

Bootstrap re-tematizado (2026-10-13)

  • assets/css/bs-brand.css (carregado depois do Bootstrap em includes/header.php e includes/auth_header.php) sobrescreve as cores do Bootstrap só com variáveis --bs-* e as variáveis de componente (.btn-*, .form-check-input, .page-link…), claro e escuro. A cor de marca é --fitme-accent (fallback #C2D500); todos os tons derivam dela com color-mix(); --brand-on é o texto sobre a cor. Secondary/success/danger/warning/info têm valores próprios. Verificação: platform.php?tool=branding (Montra da marca) não deve mostrar nenhum azul por omissão. O site público ainda não carrega este ficheiro.

Pacotes de ícones partilhados (2026-10-13)

  • config/db.php regista 'icons' => ['packs' => ['bootstrap-icons','fontawesome'], 'default' => 'bootstrap-icons'] só se shared/platform-tools/functions_icons.php existe. Flaticon NÃO está registado: o site usa PNGs (uploads/modalidades/icons), não a fonte.
  • fit_icon_assets($packs, $fallbackLocal) (config/functions_icons.php) imprime pt_icon_assets() ou, sem submódulo novo, os <link> locais. Backoffice (includes/header.php, includes/auth_header.php): bootstrap-icons; site público (site/includes/include.php): fontawesome (FA 6.7.2; as classes fas/far/fab e os nomes FA5 funcionam por aliases).
  • Sidebar > Sistema: «Pacotes de ícones» (sysadmin) -> platform.php?tool=icons.
  • Sem uso quando o submódulo é novo (não apagados): assets/vendor/bootstrap-icons/, site/assets/fonts/fontawesome/, site/assets/icon-fonts/, site/assets/css/flaticon.css. config/error_handler.php ainda usa bootstrap-icons por CDN (página de erro autónoma).