🧱Extensões de página
Uma extensão de página adiciona uma página completa ao app, servida em /x/<id>. Ela é um módulo JavaScript simples – sem etapa de build – mais uma API em Python opcional.
Onde ela aparece: todas as páginas ficam listadas em Extensões na barra lateral (a página /x). Clique no alfinete no canto superior direito de uma página (ou na página Extensões, ou ative Na barra lateral em Configurações -> Extensões) para dar a ela uma entrada própria na barra lateral, logo abaixo de Extensões; clique de novo para desafixar.
/root/maxxing/extensions/notes-board/
manifest.json
page.js the page (ES module)
api.py optional: its HTTP API
Peça ao Claude "crie uma página que ..." e a skill maxxing-extension escreve tudo para você, ou siga estes passos.
Uma instalação nova já vem com uma página, Suas páginas (extensions/pages/): ela explica o que é uma extensão de página e traz exemplos (ficha de cliente, controle de pedidos, faturas...) que abrem uma sessão com o pedido já digitado. É uma extensão comum: apague a pasta dela, ou desative-a em Configurações -> Extensões, quando não precisar mais dela – as atualizações não a trazem de volta.
1. manifest.json
{"id": "notes-board", "name": "Notes board", "type": "page", "icon": "sticky-note", "version": "1.0.0"}
name também pode ser {"en": "Notes board", "fr": "Tableau de notes"}: o app mostra o nome no idioma em que estiver. "api": 1 (opcional, padrão 1) é a versão do contrato de extensões para a qual a página foi escrita: se uma versão futura deixar de oferecer suporte a ela, a verificação prévia da atualização avisa antes de você atualizar (Configurações -> Sistema).
2. api.py (opcional)
Cada requisição a /api/ext/<id>/<path> chama:
def handle(ctx, method, path, query, body):
...
return {...} # sent as JSON (200)
# or: return 404, {"error": "not_found", "message": "..."} to choose the status code
| Argumento | Valor |
|---|---|
ctx |
igual ao dos widgets: id, workspace, state_dir, secret(name), config |
method |
GET, POST, PUT, PATCH, DELETE |
path |
a parte depois de /api/ext/<id>/ (por exemplo, notes, notes/42) |
query |
parâmetros da query string em um dict |
body |
JSON interpretado (ou texto), None para GET; máximo de 5 MB |
Ela roda em uma thread de trabalho com limite de 30 segundos. As requisições são autenticadas pelo app: somente um usuário conectado pode chamá-la.
Exemplo:
import json
import time
from pathlib import Path
def handle(ctx, method, path, query, body):
f = Path(ctx["state_dir"]) / "notes.json"
notes = json.loads(f.read_text()) if f.exists() else []
if path == "notes" and method == "GET":
return {"notes": notes}
if path == "notes" and method == "POST":
text = str((body or {}).get("text", "")).strip()[:500]
if text:
notes.insert(0, {"text": text, "at": time.time()})
tmp = f.with_suffix(".tmp")
tmp.write_text(json.dumps(notes[:200]))
tmp.replace(f)
return {"notes": notes}
return 404, {"error": "not_found", "message": f"{method} {path}"}
3. page.js
O app carrega /ext/<id>/page.js e chama mount(el, ctx):
ctx |
|
|---|---|
api(path, opts?) |
chama /api/ext/<id>/<path> com autenticação e cabeçalho CSRF; opts = {method, body} (o body é enviado como JSON); resolve com o JSON interpretado e rejeita em caso de erro HTTP |
navigate(route) |
vai para outra página do app, por exemplo navigate("/tasks"); /sessions?path=<topic path>&new=1&prompt=<text> abre uma nova sessão com o texto já digitado |
theme |
"dark" ou "light" |
lang |
o idioma do app, "en" ou "fr" |
t(key) |
função auxiliar de tradução do app |
icon(name) |
um ícone do app como elemento <svg> (mesmos nomes do icon do manifest), por exemplo button.prepend(ctx.icon("plus")) |
Retorne uma função de limpeza: ela é executada quando o usuário sai da página.
Por segurança, o app só executa scripts que ele mesmo serve: o page.js não pode adicionar tags <script> inline à página nem montar código a partir de texto (eval); em vez disso, divida o código em módulos dentro da pasta da extensão. Os widgets HTML continuam funcionando como antes.
O módulo é baixado uma vez por versão do page.js e mantido em cache: o código no topo do arquivo roda uma vez, e o mount roda a cada visita. Ao salvar o page.js, ele ganha uma nova versão, que é usada na próxima visita.
export function mount(el, ctx) {
el.innerHTML = `
<div class="ui-page" style="max-width:640px">
<h1 class="ui-h1">Notes board</h1>
<form class="ui-row">
<input class="ui-input" name="text" placeholder="New note" style="flex:1">
<button class="ui-btn">Add</button>
</form>
<ul class="ui-list"></ul>
<p class="ui-muted" hidden>No note yet: write the first one above.</p>
</div>`;
const list = el.querySelector("ul");
const empty = el.querySelector("p");
const form = el.querySelector("form");
form.querySelector("button").prepend(ctx.icon("plus"));
const render = ({ notes }) => {
list.hidden = !notes.length;
empty.hidden = !!notes.length;
list.replaceChildren(...notes.map((n) => {
const li = document.createElement("li");
li.textContent = n.text; // never inject data with innerHTML
return li;
}));
};
ctx.api("notes").then(render);
const onSubmit = async (e) => {
e.preventDefault();
const text = form.text.value.trim();
if (!text) return;
render(await ctx.api("notes", { method: "POST", body: { text } }));
form.reset();
};
form.addEventListener("submit", onSubmit);
return () => form.removeEventListener("submit", onSubmit);
}
Estilo
A página recebe o kit de UI do app: classes CSS que ficam idênticas ao resto do app e acompanham sozinhas o tema, a cor de destaque e a densidade. Prefira essas classes a estilos escritos à mão:
| Classes | |
|---|---|
ui-page ui-section ui-grid ui-row ui-stack ui-spacer |
layout: coluna da página, seção, grade responsiva de cards, linha, coluna |
ui-h1 ui-h2 ui-h3 ui-text ui-muted ui-small ui-label ui-num |
texto (título, título de seção, corpo, secundário, rótulos, números em colunas) |
ui-card ui-card-header ui-card-footer |
um card; o cabeçalho recebe um ui-icon-box e uma <div> com o título e uma linha |
ui-tile |
um card clicável: <button class="ui-tile"><div class="ui-icon-box">svg</div><div><b>Title</b><span>line</span></div></button> |
ui-icon-box (+ ui-lg, ui-neutral) |
um ícone sobre um quadrado suave na cor de destaque |
ui-btn (+ ui-secondary ui-outline ui-ghost ui-danger, ui-sm, ui-icon-only) |
botões (ui-btn sozinho = ação principal) |
ui-field ui-label ui-input ui-select ui-textarea ui-check |
formulários |
ui-badge ui-dot (+ ui-success ui-warning ui-error ui-primary ui-outline) |
status |
ui-list ui-table ui-table-wrap ui-stat ui-stat-value ui-stat-label ui-progress |
dados |
ui-steps ui-step-num ui-callout (+ ui-info ui-success ui-warning ui-error) ui-empty ui-skeleton |
passos, avisos, estados vazios e de carregamento |
ui-code ui-kbd ui-link ui-separator |
o restante |
Os ícones vêm de ctx.icon(name); use-os no lugar de emojis. O seu CSS próprio sempre prevalece sobre o kit, e as variáveis CSS do app estão disponíveis para o resto: --background, --foreground, --card, --border, --muted, --muted-foreground, --primary, --primary-foreground, --success, --warning, --destructive. Os widgets HTML recebem o mesmo kit. Os campos de entrada do kit têm 16 px em celulares (sem zoom no iPhone).
4. Testar e carregar
- Lado Python: chame
handle()diretamente (veja o trecho de teste em Widgets do painel, comapi.handle(ctx, "GET", "notes", {}, None)). - Sintaxe JavaScript:
node --check page.jsse o Node estiver instalado. - Configurações -> Extensões -> Recarregar, depois abra a página pela barra lateral (Extensões, ou a entrada própria, caso esteja fixada). Os erros aparecem na linha da extensão.