Review and approvePermission gate
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callPermission gate
The agent asks before it touches something new. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add permission-gateReact
npx designme add permission-gate --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
Permission gate is the card an agent shows when it wants to do something it has not been allowed to do yet, like editing Figma frames. The person ticks which scopes to grant, picks how long the permission lasts, and allows or declines, and they can revoke or undo that choice afterwards.
- When the agent is about to use a new tool or touch a new part of a workspace for the first time.
- When a request bundles several powers and people should be able to grant only some of them.
- When the agent is already running and reaches something it needs one more scope for, so it asks again for just that scope.
- When one of the scopes is risky, like publishing a shared library, and you want it flagged.
- When a permission should last only for this task or for today.
- Each scope has a checkbox, a
label, an optionaldetailline and an optionalrisktag such as "Risky". - A small dropdown in the footer picks how long it lasts.
durationsdefaults to This task, Today and Always, anddurationDetailsadds a line under each in the menu, like "Until midnight". - The Allow button counts the ticked scopes, like "Allow 2 scopes". It reads "Pick a scope" and stays disabled when none are ticked.
onAllowreceives the ticked keys and the duration. Return a promise and the button shows a spinner until it settles.- After allowing, the card shows what was granted and for how long, with an
allowedDetailline that crossfades and a Revoke button.allowedTitlereplaces the first line, like "Done. 6 frames edited". - While the agent works under the permission,
workingshows a spinner on a neutral row instead of the green check, andmetaadds quiet text at the end of it, like an elapsed time. followUpmarks a second ask in the middle of a run, for one more scope: the icon tile turns amber, anddeclineLabelcan say what declining means, like "Skip this frame". A scope'sriskDetailis what its risk tag shows on hover.- After Not now, the card shows
declinedTitleanddeclinedDetailwith an Undo button that brings the scopes back.
- Each scope is a native checkbox inside its label, so the whole row is clickable and keyboard reachable.
- How long is a menu button with
aria-haspopupandaria-expanded; its choices aremenuitemradioitems witharia-checked. - From the keyboard, Enter, Space or the down arrow opens a menu with the focus on its picked item, or its first (the up arrow, on its last); the arrow keys move through it and wrap, Home and End jump to the ends, Tab closes it and moves on, and closing it gives the focus back to its button.
- The Allow button is a disabled button until at least one scope is ticked.
Usage
Import it and drive it from your own state.
Import
import { PermissionGate } from '@/components/designme/permission-gate/PermissionGate';Example
The demo above as a React component, 78 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useEffect, useRef, useState } from 'react';
import { PermissionGate, type PermissionScope, type PermissionStatus } from './PermissionGate';
import { Icon } from '../lib/icons';
import { useStory, useWait } from '../lib/useScript';
/* ---------- Permission gate: hold back the risky scope, allow the rest, and grant it when the agent needs it for one frame ---------- */
const FIRST: PermissionScope[] = [
{ key: 'read', label: 'Read frames', detail: 'Pricing, Checkout' },
{ key: 'edit', label: 'Edit frames', detail: 'Only on a new page' },
{ key: 'publish', label: 'Publish library', detail: 'Affects 4 other files', risk: 'Risky', riskDetail: 'Checkout, Marketing site, Email kit and Docs use this library' }
];
const MORE: PermissionScope[] = [{ key: 'publish', label: 'Publish library', detail: 'Updates Checkout, Marketing site, Email kit and Docs', risk: 'Risky' }];
// the frames the agent edits once it may. Plan toggle is a library component, so editing it needs Publish library
const FRAMES = ['Hero', 'Plan cards', 'Compare table', 'FAQ', 'Plan toggle', 'Checkout summary'], LIB = 4;
const clock = (ms: number) => { const s = Math.floor(ms / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
export default function PermissionGateDemo() {
const ref = useRef<HTMLDivElement>(null);
const wait = useWait();
const [more, setMore] = useState(false);
const [value, setValue] = useState(['read', 'edit']);
const [duration, setDuration] = useState('This task');
const [status, setStatus] = useState<PermissionStatus>('asking');
const [working, setWorking] = useState(false);
const [title, setTitle] = useState<string>();
const [detail, setDetail] = useState<string>();
const run = useRef({ granted: 0, publish: false, skip: false, done: 0, id: 0 });
// the clock counts the agent's working time only, not the time it waits for you
const [ms, setMs] = useState(0);
useEffect(() => {
if (!working) return;
const from = performance.now() - ms, t = setInterval(() => setMs(performance.now() - from), 250);
return () => clearInterval(t);
}, [working]); // eslint-disable-line react-hooks/exhaustive-deps
// the agent works through the frames under what it was given, and stops to ask when it reaches one it may not touch
const work = async () => {
const r = run.current, id = ++r.id;
setStatus('allowed'); setWorking(true); setTitle(undefined);
while (r.done < FRAMES.length) {
if (r.done === LIB && !r.publish && !r.skip) { setWorking(false); setMore(true); setValue(['publish']); setStatus('asking'); return; }
setDetail(r.done === LIB && r.skip ? `Skipping ${FRAMES[r.done]}` : `Editing ${FRAMES[r.done]} · ${r.done + 1} of ${FRAMES.length}`);
await wait(900); if (r.id !== id) return;
r.done++;
}
setWorking(false);
setTitle(`Done. ${r.skip ? FRAMES.length - 1 : FRAMES.length} frames edited`);
setDetail(r.skip ? 'On a new page. Plan toggle skipped' : 'On a new page. Plan toggle published');
};
// the preview's story: the person ticks the risky scope, reads what it touches, leaves it off, allows, then grants it for one frame
useStory(ref, async ({ wait, press }) => {
await wait(1500); setValue(['read', 'edit', 'publish']);
await wait(2500); setValue(['read', 'edit']);
await wait(1000); press('.dm-pgx-ok');
await wait(500 + 4 * 900 + 2000); press('.dm-pgx-ok');
});
const r = run.current;
return (
<div ref={ref} style={{ width: '100%' }}>
<PermissionGate bare={false} icon={<Icon name="figma" />} durationDetails={{ 'This task': 'Until the pricing fixes are done', Today: 'Until midnight', Always: 'Until you revoke it' }}
title={more ? 'Also allow Publish library?' : 'Allow the agent to edit Figma?'}
subtitle={more ? `${FRAMES[LIB]}, frame ${LIB + 1} of ${FRAMES.length}, is a library component` : 'It needs this to apply the pricing fixes'}
scopes={more ? MORE : FIRST} value={value} onValueChange={setValue} duration={duration} onDurationChange={setDuration}
status={status} onStatusChange={setStatus} followUp={more} declineLabel={more ? 'Skip this frame' : 'Not now'}
onAllow={async keys => { r.granted += keys.length; if (keys.includes('publish')) r.publish = true; await wait(500); setMore(false); void work(); }}
onDecline={() => { if (!more) return; r.skip = true; setMore(false); void work(); }}
onRevoke={() => { run.current = { granted: 0, publish: false, skip: false, done: 0, id: r.id + 1 }; setValue(['read', 'edit']); setMs(0); setWorking(false); }}
working={working} meta={clock(ms)} allowedDetail={detail}
allowedTitle={title ?? `Allowed ${r.granted} ${r.granted > 1 ? 'scopes' : 'scope'} ${duration === 'Always' ? 'always' : 'for ' + duration.toLowerCase()}`}
declinedTitle="The agent will skip the Figma edits" declinedDetail="It will list the fixes in chat instead" />
</div>
);
}
Props
Everything PermissionGate takes.
| Prop | Type | What it does |
|---|---|---|
titlerequired | ReactNode | The question, like "Allow the agent to edit Figma?" |
subtitle | ReactNode | Why it asks |
icon | ReactNode | Shown in the tile beside the question. Defaults to a shield |
scopesrequired | PermissionScope[] | |
value | string[] | Keys of the ticked scopes. Leave it out to let the checkboxes hold their own |
defaultValue | string[] | |
onValueChange | (keys: string[]) => void | |
durations | string[] | How long the permission lasts, picked from a small dropdown in the footer. Defaults to This task, Today and Always |
durationDetails | Record<string, string> | The quiet line under each duration in its menu, by duration, like { Today: 'Until midnight' } |
duration | string | |
defaultDuration | string | |
onDurationChange | (duration: string) => void | |
status | PermissionStatus | asking, allowed or declined. Leave it out to let the buttons move it |
defaultStatus | PermissionStatus | |
onStatusChange | (status: PermissionStatus) => void | |
onAllow | (keys: string[], duration: string) => void | Promise<void> | Called with the ticked scopes and the duration. Return a promise and the button spins until it settles |
onDecline | () => void | |
onRevoke | () => void | Called by Revoke, after allowing |
onUndo | () => void | Called by Undo, after declining |
allowedDetail | string | The line under "Allowed …", like "Applying the pricing fixes now". Crossfades when it changes |
allowedTitle | string | Replaces "Allowed 2 scopes for this task", like "Done. 6 frames edited". Crossfades when it changes |
working | boolean | The agent is still working under the permission: a spinner on a neutral row instead of the green check |
meta | ReactNode | Quiet text at the end of the allowed row, like an elapsed time "0:04" |
followUp | boolean | A follow-up ask in the middle of a run, for one more scope: the icon tile turns amber |
declineLabel | string | The decline button. Defaults to "Not now"; a follow-up ask might say "Skip this frame" |
declinedTitle | ReactNode | Shown after Not now |
declinedDetail | ReactNode | |
bare | boolean | Sits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own |
className | string |
Code
The full source, as the CLI installs it.
PermissionGate.tsxReact · 177 lines
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { enter, Spin, Swap, Tip } from '../lib/motion';
import { usePopover } from '../lib/popover';
import '../designme.css';
import './permission-gate.css';
/** Something the agent is asking to be allowed to do */
export interface PermissionScope {
key: string;
/** Like "Edit frames" */
label: string;
/** The quiet line under it, like "Only on a new page" */
detail?: string;
/** A warning tag after it, like "Risky" */
risk?: string;
/** What the risk tag reveals on hover, like "Checkout, Marketing site, Email kit and Docs use this library" */
riskDetail?: string;
}
export type PermissionStatus = 'asking' | 'allowed' | 'declined';
export interface PermissionGateProps {
/** The question, like "Allow the agent to edit Figma?" */
title: ReactNode;
/** Why it asks */
subtitle?: ReactNode;
/** Shown in the tile beside the question. Defaults to a shield */
icon?: ReactNode;
scopes: PermissionScope[];
/** Keys of the ticked scopes. Leave it out to let the checkboxes hold their own */
value?: string[];
defaultValue?: string[];
onValueChange?: (keys: string[]) => void;
/** How long the permission lasts, picked from a small dropdown in the footer. Defaults to This task, Today and Always */
durations?: string[];
/** The quiet line under each duration in its menu, by duration, like { Today: 'Until midnight' } */
durationDetails?: Record<string, string>;
duration?: string;
defaultDuration?: string;
onDurationChange?: (duration: string) => void;
/** asking, allowed or declined. Leave it out to let the buttons move it */
status?: PermissionStatus;
defaultStatus?: PermissionStatus;
onStatusChange?: (status: PermissionStatus) => void;
/** Called with the ticked scopes and the duration. Return a promise and the button spins until it settles */
onAllow?: (keys: string[], duration: string) => void | Promise<void>;
onDecline?: () => void;
/** Called by Revoke, after allowing */
onRevoke?: () => void;
/** Called by Undo, after declining */
onUndo?: () => void;
/** The line under "Allowed …", like "Applying the pricing fixes now". Crossfades when it changes */
allowedDetail?: string;
/** Replaces "Allowed 2 scopes for this task", like "Done. 6 frames edited". Crossfades when it changes */
allowedTitle?: string;
/** The agent is still working under the permission: a spinner on a neutral row instead of the green check */
working?: boolean;
/** Quiet text at the end of the allowed row, like an elapsed time "0:04" */
meta?: ReactNode;
/** A follow-up ask in the middle of a run, for one more scope: the icon tile turns amber */
followUp?: boolean;
/** The decline button. Defaults to "Not now"; a follow-up ask might say "Skip this frame" */
declineLabel?: string;
/** Shown after Not now */
declinedTitle?: ReactNode;
declinedDetail?: ReactNode;
/** Sits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own */
bare?: boolean;
className?: string;
}
const DURATIONS = ['This task', 'Today', 'Always'];
const DETAILS: Record<string, string> = { 'This task': 'Until the task is done', Today: 'Until midnight', Always: 'Until you revoke it' };
// "For this task" in the menu and "for this task" after Allowed; Always reads as it is
const durLabel = (d: string) => /^always$/i.test(d) ? d : 'For ' + d.toLowerCase();
const durSpan = (d: string) => /^always$/i.test(d) ? 'always' : 'for ' + d.toLowerCase();
/** The agent asks before it acts: pick the scopes and how long they last, then allow or decline. */
export function PermissionGate({
title, subtitle, icon, scopes, value, defaultValue = [], onValueChange, durations = DURATIONS, durationDetails = DETAILS, duration, defaultDuration, onDurationChange,
status, defaultStatus = 'asking', onStatusChange, onAllow, onDecline, onRevoke, onUndo, allowedDetail, allowedTitle, working = false, meta, followUp = false,
declineLabel = 'Not now', declinedTitle = 'The agent will skip these changes', declinedDetail, bare = true, className
}: PermissionGateProps) {
const [ticked, setTicked] = useControllable(value, defaultValue, onValueChange);
const [dur, setDur] = useControllable(duration, defaultDuration ?? durations[0], onDurationChange);
const durMenu = usePopover();
const [state, setState] = useControllable(status, defaultStatus, onStatusChange);
const [waiting, setWaiting] = useState(false);
// a revoked permission fades out before the scopes come back
const [leaving, setLeaving] = useState(false);
// the scopes play their entrance when they come back, not when the card first appears
const [back, setBack] = useState(0);
const [granted, setGranted] = useState(0);
// the scopes give way to a shorter result row: the gate keeps the height it had and centres the row in it, until Revoke or Undo
const root = useRef<HTMLDivElement>(null);
const [held, setHeld] = useState<number>();
const hold = () => setHeld(h => h ?? root.current?.offsetHeight);
const n = ticked.length;
useEffect(() => {
if (!leaving) return;
const t = setTimeout(() => { setLeaving(false); setHeld(undefined); setState('asking'); setBack(b => b + 1); onRevoke?.(); }, 300);
return () => clearTimeout(t);
}, [leaving]); // eslint-disable-line react-hooks/exhaustive-deps
const toggle = (key: string) => setTicked(ticked.includes(key) ? ticked.filter(k => k !== key) : scopes.map(s => s.key).filter(k => k === key || ticked.includes(k)));
const allow = async () => {
if (!n || waiting) return;
setWaiting(true); setGranted(n);
try { await onAllow?.(ticked, dur); } finally { setWaiting(false); }
hold(); setState('allowed');
};
const decline = () => { hold(); setState('declined'); onDecline?.(); };
const undo = () => { setHeld(undefined); setState('asking'); setBack(b => b + 1); onUndo?.(); };
const again = (i: number) => (back ? { className: 'dm-enter', style: enter(i * 50).style, key: back } : { className: undefined, style: undefined, key: 0 });
return (
<div data-dm="" ref={root} className={cx('dm-pgx', !bare && 'dm-panel', followUp && state === 'asking' && 'dm-esc', held && 'dm-held', className)} style={held ? { minHeight: held } : undefined}>
<div className="dm-pgx-h">
<span className="dm-pgx-ic">{icon ?? <Icon name="shield" />}</span>
<div className="dm-pgx-ht"><b>{title}</b>{subtitle && <span className="dm-muted">{subtitle}</span>}</div>
</div>
{state === 'asking' && (
<>
<div key={'sc' + again(0).key} className={cx('dm-pgx-sc', again(0).className)} style={again(0).style}>
{scopes.map(s => (
<label key={s.key} className="dm-pgx-o">
<input type="checkbox" checked={ticked.includes(s.key)} onChange={() => toggle(s.key)} />
<span className="dm-bx"><Icon name="check" size="xs" /></span>
<span className="dm-t">{s.label}{s.detail && <span className="dm-d">{s.detail}</span>}</span>
{s.risk && (s.riskDetail ? <Tip label={s.riskDetail}><span className="dm-tag dm-warn">{s.risk}</span></Tip> : <span className="dm-tag dm-warn">{s.risk}</span>)}
</label>
))}
</div>
<div key={'f' + again(1).key} className={cx('dm-pgx-f', again(1).className)} style={again(1).style}>
<button {...durMenu.trigger} type="button" className="dm-pgx-db" aria-label="How long"><Swap className="dm-pgx-dv">{durLabel(dur)}</Swap><Icon name="chev-d" size="xs" /></button>
<span className="dm-sp" />
<button type="button" className="dm-pill dm-ghost dm-sm dm-pgx-no" onClick={decline}>{declineLabel}</button>
<button type="button" className="dm-pill dm-primary dm-sm dm-pgx-ok" disabled={!n} onClick={allow}>
{waiting ? <Spin /> : <Swap>{n ? `Allow ${n} ${n > 1 ? 'scopes' : 'scope'}` : 'Pick a scope'}</Swap>}
</button>
</div>
</>
)}
<div {...durMenu.pop} className={cx(durMenu.pop.className, 'dm-pgx-pop')} role="menu" aria-label="How long">
{durations.map(d => (
<button key={d} type="button" className="dm-mi" role="menuitemradio" aria-checked={d === dur} onClick={() => { setDur(d); durMenu.closeSoon(); }}>
<span>{durLabel(d)}{durationDetails[d] && <span className="dm-d">{durationDetails[d]}</span>}</span><span className="dm-ck"><Icon name="check" size="xs" /></span>
</button>
))}
</div>
{state === 'allowed' && (
<div className={cx('dm-pgx-done', working ? 'dm-run' : 'dm-ok', leaving ? 'dm-leave' : 'dm-enter')}>
<span className="dm-g">{working ? <Spin /> : <Icon name="ok" />}</span>
<span className="dm-t">
<b className="dm-pgx-a"><Swap>{allowedTitle ?? `Allowed ${granted || n} ${(granted || n) > 1 ? 'scopes' : 'scope'} ${durSpan(dur)}`}</Swap></b>
{allowedDetail !== undefined && <Swap>{allowedDetail}</Swap>}
</span>
{meta !== undefined && <span className="dm-pgx-tm dm-num">{meta}</span>}
<button type="button" className="dm-pill dm-ghost dm-sm" onClick={() => setLeaving(true)}>Revoke</button>
</div>
)}
{state === 'declined' && (
<div className="dm-pgx-done dm-enter" style={{ background: 'var(--dm-chip)' }}>
<Icon name="info" />
<span className="dm-t">{declinedTitle}{declinedDetail && <span>{declinedDetail}</span>}</span>
<button type="button" className="dm-pill dm-ghost dm-sm" onClick={undo}>Undo</button>
</div>
)}
</div>
);
}
permission-gate.cssReact · 52 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Permission gate ---------- */
/* the agent asks in the chat as one card: the question, the scopes to tick, and a footer with how long and the two
answers. Without .panel it sits flat in the thread */
.dm-pgx{position:relative;width:100%;max-width:400px;margin:0 auto;padding:var(--dm-s-1) 0;display:flex;flex-direction:column;gap:var(--dm-s-3)}
.dm-pgx.dm-panel{padding:var(--dm-s-4)}
.dm-pgx-h{display:flex;gap:var(--dm-s-3);align-items:center}
.dm-pgx-ic{width:32px;height:32px;border-radius:9px;background:var(--dm-chip);display:grid;place-items:center;flex:none}
.dm-pgx-ht{display:flex;flex-direction:column;min-width:0}
.dm-pgx-ht b{font-weight:500;font-size:14px}
.dm-pgx-ht span{font-size:13px}
.dm-pgx-sc{display:flex;flex-direction:column;margin-inline:-8px}
.dm-pgx-o{display:flex;align-items:center;gap:var(--dm-s-3);padding:var(--dm-s-2);border-radius:10px;cursor:pointer;font-size:13.5px;transition:background .25s}
@media (hover:hover) and (pointer:fine){.dm-pgx-o:hover{background:var(--dm-row)}}
.dm-pgx-o input{position:absolute;opacity:0;pointer-events:none}
.dm-pgx-o .dm-bx{width:18px;height:18px;border-radius:6px;box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--dm-muted) 80%,var(--dm-surface));display:grid;place-items:center;color:transparent;flex:none;transition:background .3s var(--dm-ease),box-shadow .3s,color .3s}
.dm-pgx-o .dm-bx svg{transform:scale(.4);transition:transform .35s var(--dm-ease)}
.dm-pgx-o input:checked+.dm-bx{background:var(--dm-accent);box-shadow:inset 0 0 0 1px var(--dm-accent-mark,var(--dm-accent));color:var(--dm-on-accent)}
.dm-pgx-o input:checked+.dm-bx svg{transform:none}
.dm-pgx-o input:focus-visible+.dm-bx{outline:2px solid var(--dm-focus);outline-offset:2px}
.dm-pgx-o .dm-t{flex:1;min-width:0}
.dm-pgx-o .dm-d{display:block;font-size:12px;color:var(--dm-muted)}
.dm-pgx-f{display:flex;align-items:center;gap:6px;padding-top:var(--dm-s-3);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-pgx-f .dm-sp{flex:1}
/* on a narrow stage the duration gets its own row, and the two buttons stay together under it on the right */
@container (max-width:340px){.dm-pgx-f{flex-wrap:wrap}.dm-pgx-f .dm-sp{flex-basis:100%}.dm-pgx-no{margin:var(--dm-s-2) 0 0 auto}.dm-pgx-ok{margin-top:var(--dm-s-2)}}
/* how long: a small dropdown, the way a menu of choices usually looks */
.dm-pgx-db{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 var(--dm-s-2) 0 10px;margin-left:-2px;border-radius:var(--dm-r-pill);font-size:13px;color:var(--dm-fg2);white-space:nowrap;transition:background .16s,color .16s}
.dm-pgx-db[aria-expanded="true"]{background:var(--dm-row);color:var(--dm-fg)}
@media (hover:hover) and (pointer:fine){.dm-pgx-db:hover{background:var(--dm-row);color:var(--dm-fg)}}
.dm-pgx-db>svg{color:var(--dm-muted);transition:transform .32s var(--dm-ease)}
.dm-pgx-db[aria-expanded="true"]>svg{transform:rotate(180deg)}
.dm-pgx-pop{min-width:230px}
.dm-pgx-ok[disabled]{opacity:1;cursor:not-allowed;background:var(--dm-dis-hatch),var(--dm-dis-fill);color:var(--dm-dis);box-shadow:var(--dm-soft-ring)}
.dm-pgx-done{display:flex;align-items:center;gap:var(--dm-s-3);padding:10px 10px 10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-ok-bg);font-size:13.5px}
/* its button is a white pill on the tinted row */
.dm-pgx-done .dm-pill{background:var(--dm-surface);color:var(--dm-fg);box-shadow:var(--dm-shadow)}
@media (hover:hover) and (pointer:fine){.dm-pgx-done .dm-pill:hover{background:var(--dm-surface);box-shadow:var(--dm-shadow-pop)}}
.dm-pgx-done .dm-t{flex:1;color:var(--dm-fg)}
.dm-pgx-done .dm-t span{display:block;font-size:12px;color:var(--dm-fg2)}
.dm-pgx-done>svg{color:var(--dm-ok)}
/* while the agent works under the permission: a neutral row with a spinner, a live line and a clock; green once done */
.dm-pgx-done.dm-run{background:var(--dm-chip)}
.dm-pgx-done .dm-g{width:18px;height:18px;display:grid;place-items:center;flex:none;color:var(--dm-fg2)}
.dm-pgx-done.dm-ok .dm-g{color:var(--dm-ok)}
.dm-pgx-done .dm-t b{display:block;font-weight:400}
.dm-pgx-tm{font-size:12px;color:var(--dm-muted)}
/* asking again mid-run, for one more scope: the tile turns amber */
.dm-pgx-ic{transition:background .32s var(--dm-ease)}
.dm-pgx.dm-esc .dm-pgx-ic{background:var(--dm-warn-bg)}
.dm-pgx-scope{display:flex;flex-direction:column;gap:var(--dm-s-3)}
permission-gate.htmlHTML · 19 lines
<div class="card bare" data-c="permission-gate">
<div class="stage-in">
<div class="pgx panel">
<div class="pgx-h"><span class="pgx-ic"><svg class="i s"><use href="#i-figma"/></svg></span><div class="pgx-ht"><b>Allow the agent to edit Figma?</b><span class="muted">It needs this to apply the pricing fixes</span></div></div>
<div class="pgx-sc">
<label class="pgx-o" data-k="read"><input type="checkbox" checked><span class="bx"><svg class="i xs"><use href="#i-check"/></svg></span><span class="t">Read frames<span class="d">Pricing, Checkout</span></span></label>
<label class="pgx-o" data-k="edit"><input type="checkbox" checked><span class="bx"><svg class="i xs"><use href="#i-check"/></svg></span><span class="t">Edit frames<span class="d">Only on a new page</span></span></label>
<label class="pgx-o" data-k="publish"><input type="checkbox"><span class="bx"><svg class="i xs"><use href="#i-check"/></svg></span><span class="t">Publish library<span class="d">Affects 4 other files</span></span><span class="tag warn" data-tip="Checkout, Marketing site, Email kit and Docs use this library">Risky</span></label>
</div>
<div class="pgx-f"><button class="pgx-db" aria-haspopup="menu" aria-expanded="false" aria-label="How long" data-pop=".pgx-pop"><span class="pgx-dv">For this task</span><svg class="i xs"><use href="#i-chev-d"/></svg></button><span class="sp"></span><button class="pill ghost sm pgx-no">Not now</button><button class="pill primary sm pgx-ok">Allow 2 scopes</button></div>
<div class="pop pgx-pop" role="menu" aria-label="How long">
<button class="mi" role="menuitemradio" aria-checked="true" data-close data-v="This task"><span>For this task<span class="d">Until the pricing fixes are done</span></span><span class="ck"><svg class="i xs"><use href="#i-check"/></svg></span></button>
<button class="mi" role="menuitemradio" aria-checked="false" data-close data-v="Today"><span>For today<span class="d">Until midnight</span></span><span class="ck"><svg class="i xs"><use href="#i-check"/></svg></span></button>
<button class="mi" role="menuitemradio" aria-checked="false" data-close data-v="Always"><span>Always<span class="d">Until you revoke it</span></span><span class="ck"><svg class="i xs"><use href="#i-check"/></svg></span></button>
</div>
</div>
</div>
</div>
permission-gate.cssHTML · 51 lines
/* ---------- Permission gate ---------- */
/* the agent asks in the chat as one card: the question, the scopes to tick, and a footer with how long and the two
answers. Without .panel it sits flat in the thread */
.pgx{position:relative;width:100%;max-width:400px;margin:0 auto;padding:var(--s-1) 0;display:flex;flex-direction:column;gap:var(--s-3)}
.pgx.panel{padding:var(--s-4)}
.pgx-h{display:flex;gap:var(--s-3);align-items:center}
.pgx-ic{width:32px;height:32px;border-radius:9px;background:var(--chip);display:grid;place-items:center;flex:none}
.pgx-ht{display:flex;flex-direction:column;min-width:0}
.pgx-ht b{font-weight:500;font-size:14px}
.pgx-ht span{font-size:13px}
.pgx-sc{display:flex;flex-direction:column;margin-inline:-8px}
.pgx-o{display:flex;align-items:center;gap:var(--s-3);padding:var(--s-2);border-radius:10px;cursor:pointer;font-size:13.5px;transition:background .25s}
@media (hover:hover) and (pointer:fine){.pgx-o:hover{background:var(--row)}}
.pgx-o input{position:absolute;opacity:0;pointer-events:none}
.pgx-o .bx{width:18px;height:18px;border-radius:6px;box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--muted) 80%,var(--surface));display:grid;place-items:center;color:transparent;flex:none;transition:background .3s var(--ease),box-shadow .3s,color .3s}
.pgx-o .bx svg{transform:scale(.4);transition:transform .35s var(--ease)}
.pgx-o input:checked+.bx{background:var(--accent);box-shadow:inset 0 0 0 1px var(--accent-mark,var(--accent));color:var(--on-accent)}
.pgx-o input:checked+.bx svg{transform:none}
.pgx-o input:focus-visible+.bx{outline:2px solid var(--focus);outline-offset:2px}
.pgx-o .t{flex:1;min-width:0}
.pgx-o .d{display:block;font-size:12px;color:var(--muted)}
.pgx-f{display:flex;align-items:center;gap:6px;padding-top:var(--s-3);border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.pgx-f .sp{flex:1}
/* on a narrow stage the duration gets its own row, and the two buttons stay together under it on the right */
@container (max-width:340px){.pgx-f{flex-wrap:wrap}.pgx-f .sp{flex-basis:100%}.pgx-no{margin:var(--s-2) 0 0 auto}.pgx-ok{margin-top:var(--s-2)}}
/* how long: a small dropdown, the way a menu of choices usually looks */
.pgx-db{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 var(--s-2) 0 10px;margin-left:-2px;border-radius:var(--r-pill);font-size:13px;color:var(--fg2);white-space:nowrap;transition:background .16s,color .16s}
.pgx-db[aria-expanded="true"]{background:var(--row);color:var(--fg)}
@media (hover:hover) and (pointer:fine){.pgx-db:hover{background:var(--row);color:var(--fg)}}
.pgx-db>svg{color:var(--muted);transition:transform .32s var(--ease)}
.pgx-db[aria-expanded="true"]>svg{transform:rotate(180deg)}
.pgx-pop{min-width:230px}
.pgx-ok[disabled]{opacity:1;cursor:not-allowed;background:var(--dis-hatch),var(--dis-fill);color:var(--dis);box-shadow:var(--soft-ring)}
.pgx-done{display:flex;align-items:center;gap:var(--s-3);padding:10px 10px 10px var(--s-3);border-radius:var(--r-in);background:var(--ok-bg);font-size:13.5px}
/* its button is a white pill on the tinted row */
.pgx-done .pill{background:var(--surface);color:var(--fg);box-shadow:var(--shadow)}
@media (hover:hover) and (pointer:fine){.pgx-done .pill:hover{background:var(--surface);box-shadow:var(--shadow-pop)}}
.pgx-done .t{flex:1;color:var(--fg)}
.pgx-done .t span{display:block;font-size:12px;color:var(--fg2)}
.pgx-done>svg{color:var(--ok)}
/* while the agent works under the permission: a neutral row with a spinner, a live line and a clock; green once done */
.pgx-done.run{background:var(--chip)}
.pgx-done .g{width:18px;height:18px;display:grid;place-items:center;flex:none;color:var(--fg2)}
.pgx-done.ok .g{color:var(--ok)}
.pgx-done .t b{display:block;font-weight:400}
.pgx-tm{font-size:12px;color:var(--muted)}
/* asking again mid-run, for one more scope: the tile turns amber */
.pgx-ic{transition:background .32s var(--ease)}
.pgx.esc .pgx-ic{background:var(--warn-bg)}
.pgx-scope{display:flex;flex-direction:column;gap:var(--s-3)}
permission-gate.jsHTML · 91 lines
DM.def('permission-gate', (r, c) => {
r.innerHTML = r.innerHTML.replace('{figma}', I('figma')).replace(/\{chk\}/g, I('check','xs')).replace('{chev}', I('chev-d','xs'));
const root = $('.pgx', r), ok = $('.pgx-ok', r), no = $('.pgx-no', r), sc = $('.pgx-sc', r), title = $('.pgx-ht b', r), sub = $('.pgx-ht .muted', r);
const first = { sc:sc.innerHTML, title:title.textContent, sub:sub.textContent, no:no.textContent };
const boxes = () => $$('.pgx-o input', sc);
// how long: This task, Today or Always, from the dropdown in the footer
let dur = 'This task';
const span = () => dur === 'Always' ? 'always' : 'for ' + dur.toLowerCase();
const count = () => { const n = boxes().filter(b => b.checked).length; ok.disabled = !n; swap(ok, n ? 'Allow ' + n + (n > 1 ? ' scopes' : ' scope') : 'Pick a scope'); };
c.on(sc, 'change', count);
$$('.pgx-pop .mi', r).forEach(m => m.addEventListener('click', () => { dur = m.dataset.v; $$('.pgx-pop .mi', r).forEach(x => x.setAttribute('aria-checked', x === m)); swap($('.pgx-dv', r), dur === 'Always' ? 'Always' : 'For ' + dur.toLowerCase()); }));
const body = [sc, $('.pgx-f', r)];
// the scopes give way to a shorter result row: on the card it keeps the height it had and centres the row (hold)
const show = on => { if (!on && c.card && !root.classList.contains('held')) hold(root); body.forEach(e => e.hidden = !on); if (on) stagger(body, 50); };
// the frames the agent edits once it may. Plan toggle is a library component, so editing it needs Publish library
const F = ['Hero', 'Plan cards', 'Compare table', 'FAQ', 'Plan toggle', 'Checkout summary'], LIB = 4;
const clock = s => Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
// the clock counts the agent's working time only, not the time it waits for you
let granted = 0, publish = false, skip = false, done = 0, ms = 0, run = 0, panel = null;
function row(){
panel = html(`<div class="pgx-done run"><span class="g"><span class="spin"></span></span><span class="t"><b class="pgx-a"></b><span class="pgx-d"></span></span><span class="pgx-tm num"></span><button class="pill ghost sm">Revoke</button></div>`);
root.appendChild(panel); enter(panel); $('button', panel).onclick = revoke;
}
// the agent works through the frames under what it was given, and stops to ask when it reaches one it may not touch
function work(){
const id = ++run; if (!panel) row();
const a = $('.pgx-a', panel), d = $('.pgx-d', panel), tm = $('.pgx-tm', panel);
swap(a, `Allowed ${granted} ${granted > 1 ? 'scopes' : 'scope'} ${span()}`);
const t0 = performance.now(), now = () => ms + performance.now() - t0;
tm.textContent = clock(Math.floor(ms / 1000));
c.run(async () => {
const tick = c.every(250, () => { if (run === id) tm.textContent = clock(Math.floor(now() / 1000)); });
try {
while (done < F.length){
if (done === LIB && !publish && !skip) return ask();
swap(d, done === LIB && skip ? `Skipping ${F[done]}` : `Editing ${F[done]} · ${done + 1} of ${F.length}`);
await c.wait(900); if (run !== id) return;
done++;
}
panel.classList.replace('run', 'ok'); const g = $('.g', panel); g.innerHTML = I('ok'); enter(g);
swap(a, skip ? `Done. ${F.length - 1} frames edited` : `Done. ${F.length} frames edited`);
swap(d, skip ? 'On a new page. Plan toggle skipped' : 'On a new page. Plan toggle published');
} finally { clearInterval(tick); if (run === id) ms = now(); }
});
}
// asks again, for only the scope it now needs
function ask(){
const p = panel; panel = null; p.classList.add('leave');
root.classList.add('esc'); swap(title, 'Also allow Publish library?'); swap(sub, `${F[LIB]}, frame ${LIB + 1} of ${F.length}, is a library component`);
c.later(300, () => {
p.remove();
sc.innerHTML = `<label class="pgx-o" data-k="publish"><input type="checkbox" checked><span class="bx">${I('check','xs')}</span><span class="t">Publish library<span class="d">Updates Checkout, Marketing site, Email kit and Docs</span></span><span class="tag warn">Risky</span></label>`;
no.textContent = 'Skip this frame'; count(); show(true);
});
}
function reset(){
run++; granted = done = ms = 0; publish = skip = false;
root.classList.remove('esc'); sc.innerHTML = first.sc; swap(title, first.title); swap(sub, first.sub); no.textContent = first.no; count();
}
function revoke(){ const p = panel; panel = null; run++; p.classList.add('leave'); c.later(300, () => { p.remove(); release(root); reset(); show(true); }); }
ok.onclick = () => c.run(async () => {
const picked = boxes().filter(b => b.checked); if (!picked.length) return;
granted += picked.length; if (picked.some(b => b.closest('.pgx-o').dataset.k === 'publish')) publish = true;
ok.innerHTML = '<span class="spin"></span>'; await c.wait(500);
show(false);
if (root.classList.contains('esc')){ root.classList.remove('esc'); swap(title, first.title); swap(sub, first.sub); }
// placed inside another component (Agent harness), it reports back without the run
if (!c.card){ row(); swap($('.pgx-a', panel), `Allowed ${granted} ${granted > 1 ? 'scopes' : 'scope'} ${span()}`); swap($('.pgx-d', panel), 'Applying the pricing fixes now'); $('.pgx-tm', panel).remove();
c.later(1400, () => { if (!panel) return; panel.classList.replace('run', 'ok'); $('.g', panel).innerHTML = I('ok'); swap($('.pgx-d', panel), 'Done. 6 frames edited on a new page'); }); return; }
work();
});
no.onclick = () => {
if (root.classList.contains('esc')){ skip = true; show(false); root.classList.remove('esc'); swap(title, first.title); swap(sub, first.sub); work(); return; }
show(false); const d = html(`<div class="pgx-done" style="background:var(--chip)">${I('info')}<span class="t">The agent will skip the Figma edits<span>It will list the fixes in chat instead</span></span><button class="pill ghost sm">Undo</button></div>`);
root.appendChild(d); enter(d); $('button', d).onclick = () => { d.remove(); release(root); show(true); };
};
// the preview's story: hold back the risky scope, allow the rest, then grant it when the agent needs it for one frame
c.story(async s => {
await s.beat(3);
const pub = $('[data-k="publish"]', sc);
await s.tap(pub); await s.beat(1);
await s.point($('.tag', pub)); await s.beat(3);
await s.tap(pub); await s.beat(2);
await s.tap(ok); s.away();
await s.until(() => root.classList.contains('esc') && !sc.hidden);
await s.beat(4);
await s.tap(ok); s.away();
await s.until(() => panel && panel.classList.contains('ok'));
});
});
Questions
What people ask about it.
- Can the agent wait for the permission before it continues?
- Yes. Make
onAllowreturn a promise that resolves once your agent has the grant. The button spins while it waits, then the card switches to the allowed state. - How do I pre-tick the safe scopes?
- Pass their keys in
defaultValue, or control the ticks withvalueandonValueChange. Scopes you leave out start unticked. - What happens when the person presses Revoke?
- The allowed message fades out, the card returns to asking with the scopes shown again, and
onRevokeis called so you can withdraw the grant. - Can I use my own durations?
- Yes. Pass any list of strings as
durations. The first one is picked by default unless you setdefaultDurationorduration.
More in Review and approve
Moments where a person checks, steers or signs off on the agent's work.