mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat(mini-apps): New-app prefill + light/dark theme for apps
- New app card opens the chat sidebar and pre-fills (not sends) a 'Build me a mini-app that ' prompt via the presetMessage path - Push host theme to apps over the bridge: shim applies html.dark/.light + color-scheme and exposes getTheme/onTheme; host observes theme changes live - build-mini-app skill: generated apps must style both light and dark
This commit is contained in:
parent
c424976926
commit
61f2bcff5a
7 changed files with 75 additions and 14 deletions
|
|
@ -24,10 +24,20 @@ function appDir(id: string): string {
|
|||
*/
|
||||
export const MINIAPP_BRIDGE_JS = `
|
||||
(function () {
|
||||
var data = null, dataLoaded = false, state = null;
|
||||
var dataCbs = [], stateCbs = [];
|
||||
var data = null, dataLoaded = false, state = null, theme = 'dark';
|
||||
var dataCbs = [], stateCbs = [], themeCbs = [];
|
||||
var pending = {}, seq = 0;
|
||||
function post(msg) { parent.postMessage(msg, '*'); }
|
||||
// Apply the host theme to <html> so app CSS can use html.dark / html.light
|
||||
// (and native controls via color-scheme).
|
||||
function applyTheme(t) {
|
||||
theme = t === 'light' ? 'light' : 'dark';
|
||||
var el = document.documentElement;
|
||||
el.classList.remove('light', 'dark'); el.classList.add(theme);
|
||||
el.setAttribute('data-theme', theme);
|
||||
el.style.colorScheme = theme;
|
||||
themeCbs.forEach(function (cb) { try { cb(theme); } catch (_) {} });
|
||||
}
|
||||
function rpc(method, params) {
|
||||
var id = 'r' + (++seq);
|
||||
return new Promise(function (resolve, reject) {
|
||||
|
|
@ -49,6 +59,8 @@ export const MINIAPP_BRIDGE_JS = `
|
|||
if (m.type === 'rowboat:mini-app:state') {
|
||||
state = m.state;
|
||||
stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} });
|
||||
} else if (m.type === 'rowboat:mini-app:theme') {
|
||||
applyTheme(m.theme);
|
||||
} else if (m.type === 'rowboat:mini-app:rpc-result') {
|
||||
var p = pending[m.id];
|
||||
if (p) { delete pending[m.id]; if (m.ok) p.resolve(m.result); else p.reject(new Error(m.error || 'request failed')); }
|
||||
|
|
@ -56,6 +68,8 @@ export const MINIAPP_BRIDGE_JS = `
|
|||
});
|
||||
window.rowboat = {
|
||||
getData: function () { return data; },
|
||||
getTheme: function () { return theme; },
|
||||
onTheme: function (cb) { themeCbs.push(cb); try { cb(theme); } catch (_) {} return function () { var i = themeCbs.indexOf(cb); if (i >= 0) themeCbs.splice(i, 1); }; },
|
||||
onData: function (cb) { dataCbs.push(cb); if (dataLoaded) { try { cb(data); } catch (_) {} } return function () { var i = dataCbs.indexOf(cb); if (i >= 0) dataCbs.splice(i, 1); }; },
|
||||
refreshData: function () { return loadData(); },
|
||||
getState: function () { return state; },
|
||||
|
|
|
|||
|
|
@ -3604,6 +3604,13 @@ function App() {
|
|||
setPendingPaletteSubmit({ text, mention })
|
||||
}, [isChatSidebarOpen, handleNewChatTabInSidebar])
|
||||
|
||||
// Open the chat sidebar on a fresh tab and pre-fill (not send) a builder prompt.
|
||||
const prefillChat = useCallback((text: string) => {
|
||||
if (!isChatSidebarOpen) setIsChatSidebarOpen(true)
|
||||
handleNewChatTabInSidebar()
|
||||
setPresetMessage(text)
|
||||
}, [isChatSidebarOpen, handleNewChatTabInSidebar])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingPaletteSubmit) return
|
||||
const fileMention: FileMention | undefined = pendingPaletteSubmit.mention
|
||||
|
|
@ -5993,7 +6000,11 @@ function App() {
|
|||
</div>
|
||||
) : isAppsOpen ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<MiniAppsView initialAppId={appInitialId} initialVersion={appIdVersion} />
|
||||
<MiniAppsView
|
||||
initialAppId={appInitialId}
|
||||
initialVersion={appIdVersion}
|
||||
onNewApp={() => prefillChat('Build me a mini-app that ')}
|
||||
/>
|
||||
</div>
|
||||
) : isEmailOpen ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -28,6 +28,15 @@ export function MiniAppFrame({ manifest }: { manifest: miniApp.MiniAppManifest }
|
|||
iframeRef.current?.contentWindow?.postMessage(message, '*')
|
||||
}
|
||||
|
||||
const currentTheme = (): 'light' | 'dark' =>
|
||||
document.documentElement.classList.contains('dark') ? 'dark' : 'light'
|
||||
|
||||
// Push host theme changes into the app so it can restyle live.
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
postToFrame({ type: MINI_APP_MESSAGE.theme, theme: currentTheme() })
|
||||
})
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||
|
||||
async function handleRpc(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
// fetch is a network proxy, not toolkit-scoped — handle before the scope gate.
|
||||
if (method === 'fetch') {
|
||||
|
|
@ -74,9 +83,10 @@ export function MiniAppFrame({ manifest }: { manifest: miniApp.MiniAppManifest }
|
|||
}
|
||||
|
||||
// Apps load their own data.json (served sibling) via a relative fetch; the
|
||||
// host only provides per-app UI state on ready.
|
||||
// host provides per-app UI state and the current theme on ready.
|
||||
function handleReady() {
|
||||
postToFrame({ type: MINI_APP_MESSAGE.state, state: stateRef.current })
|
||||
postToFrame({ type: MINI_APP_MESSAGE.theme, theme: currentTheme() })
|
||||
}
|
||||
|
||||
function handleMessage(event: MessageEvent) {
|
||||
|
|
@ -112,7 +122,10 @@ export function MiniAppFrame({ manifest }: { manifest: miniApp.MiniAppManifest }
|
|||
}
|
||||
|
||||
window.addEventListener('message', handleMessage)
|
||||
return () => window.removeEventListener('message', handleMessage)
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage)
|
||||
themeObserver.disconnect()
|
||||
}
|
||||
}, [manifest.id, scope])
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -132,10 +132,11 @@ const CARD_CSS = `
|
|||
.ma-lastrun { font-size:11.5px; color:var(--ma-lastrun); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
|
||||
.ma-new {
|
||||
min-height:clamp(190px,24cqw,244px); border-radius:18px; border:1px dashed var(--ma-new-border);
|
||||
width:100%; font:inherit; min-height:clamp(190px,24cqw,244px); border-radius:18px; border:1px dashed var(--ma-new-border);
|
||||
background:transparent; display:flex; flex-direction:column; align-items:center; justify-content:center;
|
||||
gap:8px; color:var(--ma-new-title); cursor:default; transition: border-color .2s ease, color .2s ease;
|
||||
gap:8px; color:var(--ma-new-title); cursor:pointer; transition: border-color .2s ease, color .2s ease, background .2s ease;
|
||||
}
|
||||
.ma-new:hover { border-color:var(--ma-border-hover); background:color-mix(in srgb, var(--accent, #888) 6%, transparent); }
|
||||
.ma-new-title { font-size:14.5px; font-weight:600; color:var(--ma-new-title); }
|
||||
.ma-new-hint { font-size:12px; color:var(--ma-new-hint); text-align:center; padding:0 12px; }
|
||||
|
||||
|
|
@ -170,7 +171,7 @@ function Card({ app, index, onOpen }: { app: miniApp.MiniAppManifest; index: num
|
|||
)
|
||||
}
|
||||
|
||||
export function MiniAppsView({ initialAppId, initialVersion }: { initialAppId?: string | null; initialVersion?: number } = {}) {
|
||||
export function MiniAppsView({ initialAppId, initialVersion, onNewApp }: { initialAppId?: string | null; initialVersion?: number; onNewApp?: () => void } = {}) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(initialAppId ?? null)
|
||||
const [manifests, setManifests] = useState<miniApp.MiniAppManifest[]>([])
|
||||
|
||||
|
|
@ -230,12 +231,12 @@ export function MiniAppsView({ initialAppId, initialVersion }: { initialAppId?:
|
|||
<Card key={m.id} app={m} index={i} onOpen={() => setSelectedId(m.id)} />
|
||||
))}
|
||||
|
||||
{/* Placeholder for copilot-generated apps (Phase 3). */}
|
||||
<div className="ma-new">
|
||||
{/* Kick off the copilot builder with a pre-filled prompt. */}
|
||||
<button type="button" className="ma-new" onClick={onNewApp}>
|
||||
<Plus className="size-5" />
|
||||
<div className="ma-new-title">New app</div>
|
||||
<div className="ma-new-hint">Describe one to the copilot (coming soon)</div>
|
||||
</div>
|
||||
<div className="ma-new-hint">Describe one to the copilot</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,10 +15,18 @@
|
|||
*/
|
||||
const BRIDGE_SHIM = /* js */ `
|
||||
(function () {
|
||||
var data = null, dataLoaded = false, state = null;
|
||||
var dataCbs = [], stateCbs = [];
|
||||
var data = null, dataLoaded = false, state = null, theme = 'dark';
|
||||
var dataCbs = [], stateCbs = [], themeCbs = [];
|
||||
var pending = {}, seq = 0;
|
||||
function post(msg) { parent.postMessage(msg, '*'); }
|
||||
function applyTheme(t) {
|
||||
theme = t === 'light' ? 'light' : 'dark';
|
||||
var el = document.documentElement;
|
||||
el.classList.remove('light', 'dark'); el.classList.add(theme);
|
||||
el.setAttribute('data-theme', theme);
|
||||
el.style.colorScheme = theme;
|
||||
themeCbs.forEach(function (cb) { try { cb(theme); } catch (_) {} });
|
||||
}
|
||||
function rpc(method, params) {
|
||||
var id = 'r' + (++seq);
|
||||
return new Promise(function (resolve, reject) {
|
||||
|
|
@ -39,6 +47,8 @@ const BRIDGE_SHIM = /* js */ `
|
|||
if (m.type === 'rowboat:mini-app:state') {
|
||||
state = m.state;
|
||||
stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} });
|
||||
} else if (m.type === 'rowboat:mini-app:theme') {
|
||||
applyTheme(m.theme);
|
||||
} else if (m.type === 'rowboat:mini-app:rpc-result') {
|
||||
var p = pending[m.id];
|
||||
if (p) {
|
||||
|
|
@ -50,6 +60,8 @@ const BRIDGE_SHIM = /* js */ `
|
|||
window.rowboat = {
|
||||
getData: function () { return data; },
|
||||
refreshData: function () { return loadData(); },
|
||||
getTheme: function () { return theme; },
|
||||
onTheme: function (cb) { themeCbs.push(cb); try { cb(theme); } catch (_) {} return function () { var i = themeCbs.indexOf(cb); if (i >= 0) themeCbs.splice(i, 1); }; },
|
||||
onData: function (cb) {
|
||||
dataCbs.push(cb);
|
||||
if (dataLoaded) { try { cb(data); } catch (_) {} }
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ export type MiniAppInboundMessage =
|
|||
| { type: 'rowboat:mini-app:data'; data: unknown }
|
||||
/** Current per-app state; sent on ready and after setState. */
|
||||
| { type: 'rowboat:mini-app:state'; state: unknown }
|
||||
/** Host theme; sent on ready and whenever the app theme changes. */
|
||||
| { type: 'rowboat:mini-app:theme'; theme: 'light' | 'dark' }
|
||||
/** Result of a previously requested rpc call, correlated by id. */
|
||||
| { type: 'rowboat:mini-app:rpc-result'; id: string; ok: boolean; result?: unknown; error?: string }
|
||||
|
||||
|
|
@ -80,5 +82,6 @@ export const MINI_APP_MESSAGE = {
|
|||
setState: 'rowboat:mini-app:setState',
|
||||
data: 'rowboat:mini-app:data',
|
||||
state: 'rowboat:mini-app:state',
|
||||
theme: 'rowboat:mini-app:theme',
|
||||
rpcResult: 'rowboat:mini-app:rpc-result',
|
||||
} as const
|
||||
|
|
|
|||
|
|
@ -81,6 +81,13 @@ bridge: include the shim and code against \`window.rowboat\`:
|
|||
Keep it dependency-free (no remote CDNs unless truly needed; the app:// origin
|
||||
allows them but offline-safe is better). Render loading / empty / error states.
|
||||
|
||||
**Support light AND dark.** The bridge applies the host theme to \`<html>\` — it
|
||||
sets the class \`dark\` or \`light\` (and \`color-scheme\`) and updates live when the
|
||||
user switches. Write CSS for BOTH: style defaults for light, then override under
|
||||
\`html.dark { … }\` (or use CSS variables keyed on the theme). Never hard-code a
|
||||
dark-only palette. \`rowboat.getTheme()\`/\`onTheme(cb)\` are also available if you
|
||||
need JS. Do not build dark-only.
|
||||
|
||||
**Who writes this HTML:**
|
||||
- If a **coding agent is active** (user toggled the Code chip), delegate authoring
|
||||
via the \`code-with-agents\` skill — run it with \`cwd\` = the app's
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue