Add Diceroll story-steering extension Before each response, quietly asks the current model for 5-10 short continuation options with probabilities (structured output with plain-text fallback), rolls a weighted die, and injects the chosen direction after the latest user message for that generation only. Both side prompts share the main chat's exact history prefix so the provider prompt cache is reused. Swipes/regenerates reuse the rolled direction unless re-rolling is enabled; a debug mode shows the option table and injected prompt under each message.

ce847b8b518928c40f2b1603fecc143114ecd24a

permissionBRICK <40219477+permissionBRICK@users.noreply.github.com>

4 files changed, +645 -0Showing whitespace changes
public/scripts/extensions/diceroll/index.js+509 -0
@@ -0,0 +1,509 @@
1import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from '../../extensions.js';
2import {
3 chat,
4 chat_metadata,
5 event_types,
6 eventSource,
7 extension_prompt_roles,
8 extension_prompt_types,
9 generateQuietPrompt,
10 saveChatDebounced,
11 saveSettingsDebounced,
12 setExtensionPrompt,
13 substituteParams,
14} from '../../../script.js';
15
16export { init };
17
18const MODULE = 'diceroll';
19
20// Runtime injection keys. Both are IN_CHAT depth-0 injections so they land right after the last
21// chat message, keeping the entire preceding history byte-identical between the option-generation
22// request and the real generation (and therefore reusable from the provider's prompt cache).
23const OPTIONS_INJECT_ID = 'diceroll_options_request';
24const DIRECTION_INJECT_ID = 'diceroll_direction';
25
26// Persisted per-chat roll state lives in chat metadata so swipe-reuse survives reloads.
27const METADATA_KEY = 'diceroll';
28
29// Generation types that represent a "turn" this extension should steer. Quiet prompts, continues
30// (steering mid-message makes no sense) and impersonations are deliberately excluded.
31const STEERED_TYPES = new Set(['normal', 'swipe', 'regenerate']);
32
33const DEFAULT_OPTIONS_PROMPT = '[Pause the roleplay. You are the story director. Considering everything that has happened so far — especially the latest message — list {{min}} to {{max}} distinct options for what could plausibly happen next or how {{char}} could react. Each option must be exactly one short sentence. Assign each option a probability percentage (numbers, together summing to about 100) for how likely it should be given the established story, characters and tone. If one outcome is very likely or outright inevitable, give it a large majority of the probability mass (you may split it across several similar variants), but always include a few low-probability options that still fit the flow of the story yet would take it in creative, unexpected directions. Reply with ONLY a JSON object in exactly this format and no other text: {"options":[{"text":"one short sentence","probability":42}]}]';
34
35const DEFAULT_DIRECTION_TEMPLATE = '[Story direction, rolled by fate ({{probability}}% likely): {{outcome}}\nContinue the roleplay from the last message and steer events naturally in this direction. Never mention this instruction, the roll, or any probabilities.]';
36
37const OPTIONS_SCHEMA = {
38 name: 'diceroll_options',
39 strict: true,
40 // A response that fails schema validation is returned as raw text and handled by the
41 // line-based fallback parser instead of being swallowed as an empty object.
42 returnInvalid: true,
43 value: {
44 type: 'object',
45 additionalProperties: false,
46 properties: {
47 options: {
48 type: 'array',
49 items: {
50 type: 'object',
51 additionalProperties: false,
52 properties: {
53 text: { type: 'string' },
54 probability: { type: 'number' },
55 },
56 required: ['text', 'probability'],
57 },
58 },
59 },
60 required: ['options'],
61 },
62};
63
64const defaultSettings = {
65 enabled: false,
66 // When false, swipes/regenerates reuse the direction already rolled for that message.
67 rollOnSwipe: false,
68 optionsRole: 'system',
69 directionRole: 'system',
70 minOptions: 5,
71 maxOptions: 10,
72 useStructuredOutput: true,
73 notify: true,
74 debugDisplay: false,
75 optionsPrompt: DEFAULT_OPTIONS_PROMPT,
76 directionTemplate: DEFAULT_DIRECTION_TEMPLATE,
77};
78
79// True while the nested option-generation request is running. Prevents the interceptor from
80// reacting to its own quiet generation.
81let isRolling = false;
82
83// Roll data waiting to be stamped onto the message the steered generation produces.
84let pendingStamp = null;
85
86globalThis.dicerollGenerateInterceptor = (...args) => onGenerationIntercept(...args);
87
88function getSettings() {
89 if (extension_settings[MODULE] === undefined) {
90 extension_settings[MODULE] = {};
91 }
92 for (const key of Object.keys(defaultSettings)) {
93 if (extension_settings[MODULE][key] === undefined) {
94 extension_settings[MODULE][key] = structuredClone(defaultSettings[key]);
95 }
96 }
97 return extension_settings[MODULE];
98}
99
100function getRole(name) {
101 return name === 'user' ? extension_prompt_roles.USER : extension_prompt_roles.SYSTEM;
102}
103
104function setDirectionInjection(text, roleName) {
105 setExtensionPrompt(DIRECTION_INJECT_ID, text, extension_prompt_types.IN_CHAT, 0, false, getRole(roleName));
106}
107
108function clearDirectionInjection() {
109 setDirectionInjection('', 'system');
110}
111
112function formatProbability(value) {
113 const number = Number(value);
114 return Number.isInteger(number) ? String(number) : number.toFixed(1);
115}
116
117/**
118 * Runs the option-generation request: the full current chat history plus the instruction injected
119 * at depth 0, through the currently selected model/API.
120 * @param {object} s Settings
121 * @returns {Promise<string>} Raw model response
122 */
123async function generateOptions(s) {
124 const instruction = substituteParams(
125 String(s.optionsPrompt)
126 .replaceAll('{{min}}', String(s.minOptions))
127 .replaceAll('{{max}}', String(s.maxOptions)),
128 );
129 setExtensionPrompt(OPTIONS_INJECT_ID, instruction, extension_prompt_types.IN_CHAT, 0, false, getRole(s.optionsRole));
130 try {
131 return await generateQuietPrompt({
132 quietPrompt: '',
133 jsonSchema: s.useStructuredOutput ? OPTIONS_SCHEMA : null,
134 });
135 } finally {
136 setExtensionPrompt(OPTIONS_INJECT_ID, '', extension_prompt_types.IN_CHAT, 0);
137 }
138}
139
140function tryParseJson(text) {
141 const candidates = [text];
142 const objStart = text.indexOf('{');
143 const objEnd = text.lastIndexOf('}');
144 if (objStart >= 0 && objEnd > objStart) {
145 candidates.push(text.slice(objStart, objEnd + 1));
146 }
147 const arrStart = text.indexOf('[');
148 const arrEnd = text.lastIndexOf(']');
149 if (arrStart >= 0 && arrEnd > arrStart) {
150 candidates.push(text.slice(arrStart, arrEnd + 1));
151 }
152 for (const candidate of candidates) {
153 try {
154 return JSON.parse(candidate);
155 } catch {
156 // try the next candidate
157 }
158 }
159 return null;
160}
161
162/**
163 * Parses model output into a weighted option list. Prefers JSON (structured output or inline),
164 * falls back to "text (25%)" / "25% - text" style lines.
165 * @param {string} raw Raw model response
166 * @returns {{text: string, probability: number}[]|null} At least two options, or null
167 */
168function parseOptions(raw) {
169 const text = String(raw ?? '').trim();
170 if (!text) {
171 return null;
172 }
173
174 let options = [];
175 const parsed = tryParseJson(text);
176 const list = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.options) ? parsed.options : null);
177 if (list) {
178 options = list.map(item => ({
179 text: String(item?.text ?? item?.option ?? '').trim(),
180 probability: Number(item?.probability ?? item?.percent ?? item?.chance),
181 }));
182 } else {
183 const trailing = /^(.+?)\s*[-–—:([]?\s*(\d{1,3}(?:\.\d+)?)\s*%\s*[)\]]?\s*$/;
184 const leading = /^(\d{1,3}(?:\.\d+)?)\s*%\s*[-–—:]?\s*(.+)$/;
185 for (let line of text.split('\n')) {
186 line = line.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, '').trim();
187 let match = line.match(trailing);
188 if (match) {
189 options.push({ text: match[1].trim(), probability: Number(match[2]) });
190 continue;
191 }
192 match = line.match(leading);
193 if (match) {
194 options.push({ text: match[2].trim(), probability: Number(match[1]) });
195 }
196 }
197 }
198
199 options = options.filter(option => option.text && Number.isFinite(option.probability) && option.probability > 0);
200
201 // Models occasionally return fractions instead of percentages.
202 const total = options.reduce((sum, option) => sum + option.probability, 0);
203 if (total > 0 && total <= 1.5) {
204 options = options.map(option => ({ ...option, probability: option.probability * 100 }));
205 }
206
207 return options.length >= 2 ? options : null;
208}
209
210/**
211 * Weighted random roll over the options.
212 * @param {{text: string, probability: number}[]} options Parsed options
213 * @returns {{options: object[], chosenIndex: number, roll: number, total: number}}
214 */
215function rollOptions(options) {
216 const total = options.reduce((sum, option) => sum + option.probability, 0);
217 const roll = Math.random() * total;
218 let cumulative = 0;
219 let chosenIndex = options.length - 1;
220 for (let i = 0; i < options.length; i++) {
221 cumulative += options[i].probability;
222 if (roll < cumulative) {
223 chosenIndex = i;
224 break;
225 }
226 }
227 return { options, chosenIndex, roll, total };
228}
229
230function buildDirectionText(rollData, s) {
231 const chosen = rollData.options[rollData.chosenIndex];
232 return substituteParams(
233 String(s.directionTemplate)
234 .replaceAll('{{outcome}}', chosen.text)
235 .replaceAll('{{probability}}', formatProbability(chosen.probability)),
236 );
237}
238
239/**
240 * Temporarily hides the message being swiped away so the option request sees the same history as
241 * the swipe generation itself (which pops the last message from its prompt).
242 * @returns {(() => void)|null} Restore function
243 */
244function hideLastMessageForSwipe() {
245 const target = chat[chat.length - 1];
246 if (!target || target.is_user || target.is_system) {
247 return null;
248 }
249 target.is_system = true;
250 return () => {
251 target.is_system = false;
252 };
253}
254
255/**
256 * Generation interceptor. Runs the option roll before the actual generation and injects the
257 * rolled direction for the upcoming prompt build.
258 * @param {object[]} coreChat Filtered chat that will be used for the prompt
259 * @param {number} _contextSize Max context size
260 * @param {(immediately: boolean) => void} _abort Abort function
261 * @param {string} type Generation type
262 */
263async function onGenerationIntercept(coreChat, _contextSize, _abort, type) {
264 const s = getSettings();
265
266 // The nested option request triggers interceptors itself (as type 'quiet').
267 if (isRolling || type === 'quiet') {
268 return;
269 }
270
271 if (!s.enabled) {
272 clearDirectionInjection();
273 return;
274 }
275
276 // Tool-call recursion re-enters Generate as 'normal'; keep the current steering untouched
277 // instead of rolling again mid-turn.
278 const lastMessage = chat[chat.length - 1];
279 if (lastMessage && !lastMessage.is_user && Array.isArray(lastMessage.extra?.tool_invocations)) {
280 return;
281 }
282
283 // Never leak a stale direction into an unrelated generation type.
284 clearDirectionInjection();
285 pendingStamp = null;
286
287 if (!STEERED_TYPES.has(type) || !chat.length) {
288 return;
289 }
290
291 // The roll belongs to the current user turn; swipes/regenerates of the same turn can reuse it.
292 const anchor = chat.findLastIndex(x => x.is_user);
293 const stored = chat_metadata[METADATA_KEY];
294 const isRedo = type === 'swipe' || type === 'regenerate';
295
296 if (isRedo && !s.rollOnSwipe && stored?.direction && stored.anchor === anchor) {
297 pendingStamp = stored;
298 setDirectionInjection(stored.direction, s.directionRole);
299 return;
300 }
301
302 let rollData = null;
303 isRolling = true;
304 const restoreHidden = type === 'swipe' ? hideLastMessageForSwipe() : null;
305 try {
306 const raw = await generateOptions(s);
307 const options = parseOptions(raw);
308 if (!options) {
309 throw new Error('Could not parse any options from the model response.');
310 }
311 rollData = rollOptions(options);
312 } catch (error) {
313 console.error('[Diceroll] Option generation failed:', error);
314 toastr.warning('Continuing without steering. ' + (error?.message ?? ''), 'Diceroll: option roll failed', { escapeHtml: true });
315 } finally {
316 restoreHidden?.();
317 isRolling = false;
318 }
319
320 if (!rollData) {
321 return;
322 }
323
324 const data = {
325 anchor,
326 options: rollData.options,
327 chosenIndex: rollData.chosenIndex,
328 roll: rollData.roll,
329 total: rollData.total,
330 direction: '',
331 };
332 data.direction = buildDirectionText(rollData, s);
333
334 chat_metadata[METADATA_KEY] = data;
335 saveMetadataDebounced();
336 pendingStamp = data;
337 setDirectionInjection(data.direction, s.directionRole);
338
339 if (s.notify) {
340 const chosen = rollData.options[rollData.chosenIndex];
341 toastr.info(`${chosen.text} (${formatProbability(chosen.probability)}%)`, '🎲 Diceroll', { escapeHtml: true });
342 }
343}
344
345/**
346 * Copies the roll that steered a finished generation onto the produced message, so the debug view
347 * stays correct per message and survives reloads.
348 * @param {number} chatId Message index
349 */
350function stampMessage(chatId) {
351 if (!pendingStamp) {
352 return;
353 }
354 const message = chat[chatId];
355 if (!message || message.is_user || message.is_system) {
356 return;
357 }
358 message.extra = message.extra || {};
359 message.extra.diceroll = structuredClone(pendingStamp);
360 pendingStamp = null;
361 saveChatDebounced();
362}
363
364function renderDebugForMessage(chatId) {
365 const mesElement = $(`#chat .mes[mesid="${chatId}"]`);
366 if (!mesElement.length) {
367 return;
368 }
369 mesElement.find('.diceroll_debug').remove();
370
371 const data = chat[chatId]?.extra?.diceroll;
372 if (!getSettings().debugDisplay || !data || !Array.isArray(data.options)) {
373 return;
374 }
375
376 const chosen = data.options[data.chosenIndex];
377 const details = $('<details class="diceroll_debug"></details>');
378 const rollInfo = Number.isFinite(data.roll) ? `, roll ${data.roll.toFixed(1)}/${formatProbability(data.total)}` : '';
379 details.append($('<summary></summary>').text(`🎲 ${chosen?.text ?? '?'} (${formatProbability(chosen?.probability ?? 0)}%${rollInfo})`));
380
381 const table = $('<table class="diceroll_debug_table"></table>');
382 data.options.forEach((option, index) => {
383 const row = $('<tr></tr>').toggleClass('diceroll_chosen', index === data.chosenIndex);
384 row.append($('<td></td>').text(`${formatProbability(option.probability)}%`));
385 row.append($('<td></td>').text(option.text));
386 table.append(row);
387 });
388 details.append(table);
389 details.append($('<div class="diceroll_debug_direction"></div>').text(data.direction ?? ''));
390
391 const anchorElement = mesElement.find('.mes_block .mes_text').first();
392 if (anchorElement.length) {
393 anchorElement.after(details);
394 } else {
395 mesElement.append(details);
396 }
397}
398
399function renderAllDebug() {
400 $('#chat .mes').each((_, element) => {
401 renderDebugForMessage(Number(element.getAttribute('mesid')));
402 });
403}
404
405function loadSettingsUi() {
406 const s = getSettings();
407 $('#diceroll_enabled').prop('checked', s.enabled);
408 $('#diceroll_roll_on_swipe').prop('checked', s.rollOnSwipe);
409 $('#diceroll_structured').prop('checked', s.useStructuredOutput);
410 $('#diceroll_notify').prop('checked', s.notify);
411 $('#diceroll_debug').prop('checked', s.debugDisplay);
412 $('#diceroll_min_options').val(s.minOptions);
413 $('#diceroll_max_options').val(s.maxOptions);
414 $('#diceroll_options_role').val(s.optionsRole);
415 $('#diceroll_direction_role').val(s.directionRole);
416 $('#diceroll_options_prompt').val(s.optionsPrompt);
417 $('#diceroll_direction_template').val(s.directionTemplate);
418}
419
420function setupListeners() {
421 const bindCheckbox = (id, key, onChange = null) => {
422 $(id).on('change', function () {
423 getSettings()[key] = !!$(this).prop('checked');
424 saveSettingsDebounced();
425 onChange?.();
426 });
427 };
428
429 bindCheckbox('#diceroll_enabled', 'enabled', () => {
430 if (!getSettings().enabled) {
431 clearDirectionInjection();
432 }
433 });
434 bindCheckbox('#diceroll_roll_on_swipe', 'rollOnSwipe');
435 bindCheckbox('#diceroll_structured', 'useStructuredOutput');
436 bindCheckbox('#diceroll_notify', 'notify');
437 bindCheckbox('#diceroll_debug', 'debugDisplay', renderAllDebug);
438
439 $('#diceroll_min_options').on('input', function () {
440 getSettings().minOptions = Math.max(2, Number($(this).val()) || defaultSettings.minOptions);
441 saveSettingsDebounced();
442 });
443 $('#diceroll_max_options').on('input', function () {
444 getSettings().maxOptions = Math.max(2, Number($(this).val()) || defaultSettings.maxOptions);
445 saveSettingsDebounced();
446 });
447 $('#diceroll_options_role').on('change', function () {
448 getSettings().optionsRole = String($(this).val());
449 saveSettingsDebounced();
450 });
451 $('#diceroll_direction_role').on('change', function () {
452 getSettings().directionRole = String($(this).val());
453 saveSettingsDebounced();
454 });
455 $('#diceroll_options_prompt').on('input', function () {
456 getSettings().optionsPrompt = String($(this).val());
457 saveSettingsDebounced();
458 });
459 $('#diceroll_direction_template').on('input', function () {
460 getSettings().directionTemplate = String($(this).val());
461 saveSettingsDebounced();
462 });
463 $('#diceroll_options_prompt_restore').on('click', () => {
464 getSettings().optionsPrompt = DEFAULT_OPTIONS_PROMPT;
465 $('#diceroll_options_prompt').val(DEFAULT_OPTIONS_PROMPT);
466 saveSettingsDebounced();
467 });
468 $('#diceroll_direction_template_restore').on('click', () => {
469 getSettings().directionTemplate = DEFAULT_DIRECTION_TEMPLATE;
470 $('#diceroll_direction_template').val(DEFAULT_DIRECTION_TEMPLATE);
471 saveSettingsDebounced();
472 });
473}
474
475async function init() {
476 const settingsHtml = await renderExtensionTemplateAsync(MODULE, 'settings');
477 $('#extensions_settings2').append(settingsHtml);
478 loadSettingsUi();
479 setupListeners();
480
481 // A new user message starts a new turn; any previous roll no longer applies.
482 eventSource.on(event_types.MESSAGE_SENT, () => {
483 delete chat_metadata[METADATA_KEY];
484 });
485
486 eventSource.on(event_types.MESSAGE_RECEIVED, (chatId) => stampMessage(chatId));
487 eventSource.on(event_types.CHARACTER_MESSAGE_RENDERED, (chatId) => {
488 stampMessage(chatId);
489 renderDebugForMessage(chatId);
490 });
491 eventSource.on(event_types.MESSAGE_SWIPED, (chatId) => renderDebugForMessage(chatId));
492 eventSource.on(event_types.CHAT_CHANGED, () => {
493 pendingStamp = null;
494 clearDirectionInjection();
495 renderAllDebug();
496 });
497
498 // The direction is consumed by exactly one generation. GENERATION_ENDED also fires when the
499 // nested option request finishes, which is why clearing is skipped while a roll is in flight.
500 const onGenerationDone = () => {
501 if (isRolling) {
502 return;
503 }
504 pendingStamp = null;
505 clearDirectionInjection();
506 };
507 eventSource.on(event_types.GENERATION_ENDED, onGenerationDone);
508 eventSource.on(event_types.GENERATION_STOPPED, onGenerationDone);
509}
public/scripts/extensions/diceroll/manifest.json+15 -0
@@ -0,0 +1,15 @@
1{
2 "display_name": "Diceroll",
3 "loading_order": 100,
4 "requires": [],
5 "optional": [],
6 "js": "index.js",
7 "css": "style.css",
8 "author": "SillyTavern",
9 "version": "1.0.0",
10 "homePage": "https://github.com/SillyTavern/SillyTavern",
11 "generate_interceptor": "dicerollGenerateInterceptor",
12 "hooks": {
13 "activate": "init"
14 }
15}
public/scripts/extensions/diceroll/settings.html+79 -0
@@ -0,0 +1,79 @@
1<div id="diceroll_settings">
2 <div class="inline-drawer">
3 <div class="inline-drawer-toggle inline-drawer-header">
4 <b data-i18n="ext_diceroll_title">Diceroll</b>
5 <div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
6 </div>
7 <div class="inline-drawer-content">
8 <label class="checkbox_label" for="diceroll_enabled">
9 <input id="diceroll_enabled" type="checkbox" />
10 <span data-i18n="ext_diceroll_enable">Enable Diceroll</span>
11 </label>
12 <small data-i18n="ext_diceroll_help">Before each response, the current model is quietly asked (with the exact same chat history, so the prompt cache is reused) to list short options for how the story could continue, each with a probability. A weighted dice roll picks one, and the chosen direction is injected after the latest user message for that one generation only — it is never saved to the chat, and the next turn's history stays clean.</small>
13
14 <label class="checkbox_label" for="diceroll_roll_on_swipe">
15 <input id="diceroll_roll_on_swipe" type="checkbox" />
16 <span data-i18n="ext_diceroll_roll_on_swipe">Roll again on swipes and regenerates</span>
17 </label>
18 <small data-i18n="ext_diceroll_roll_on_swipe_help">When off, swiping or regenerating reuses the direction already rolled for that turn.</small>
19
20 <label class="checkbox_label" for="diceroll_structured">
21 <input id="diceroll_structured" type="checkbox" />
22 <span data-i18n="ext_diceroll_structured">Request structured output (JSON schema)</span>
23 </label>
24 <small data-i18n="ext_diceroll_structured_help">Turn off if your backend rejects requests containing a JSON schema; the options are then parsed from plain text.</small>
25
26 <label class="checkbox_label" for="diceroll_notify">
27 <input id="diceroll_notify" type="checkbox" />
28 <span data-i18n="ext_diceroll_notify">Show a notification with the rolled outcome</span>
29 </label>
30
31 <label class="checkbox_label" for="diceroll_debug">
32 <input id="diceroll_debug" type="checkbox" />
33 <span data-i18n="ext_diceroll_debug">Debug: show the roll table and injected direction under each message</span>
34 </label>
35
36 <div class="flex-container">
37 <div class="flex1">
38 <label for="diceroll_min_options" data-i18n="ext_diceroll_min">Min options</label>
39 <input id="diceroll_min_options" class="text_pole" type="number" min="2" max="20" step="1" />
40 </div>
41 <div class="flex1">
42 <label for="diceroll_max_options" data-i18n="ext_diceroll_max">Max options</label>
43 <input id="diceroll_max_options" class="text_pole" type="number" min="2" max="20" step="1" />
44 </div>
45 </div>
46
47 <div class="flex-container">
48 <div class="flex1">
49 <label for="diceroll_options_role" data-i18n="ext_diceroll_options_role">Option request sent as</label>
50 <select id="diceroll_options_role" class="text_pole">
51 <option value="system" data-i18n="ext_diceroll_role_system">System message</option>
52 <option value="user" data-i18n="ext_diceroll_role_user">User message</option>
53 </select>
54 </div>
55 <div class="flex1">
56 <label for="diceroll_direction_role" data-i18n="ext_diceroll_direction_role">Direction sent as</label>
57 <select id="diceroll_direction_role" class="text_pole">
58 <option value="system" data-i18n="ext_diceroll_role_system">System message</option>
59 <option value="user" data-i18n="ext_diceroll_role_user">User message</option>
60 </select>
61 </div>
62 </div>
63
64 <label for="diceroll_options_prompt" data-i18n="ext_diceroll_options_prompt">Option-generation prompt (\{{min}}, \{{max}} available)</label>
65 <textarea id="diceroll_options_prompt" class="text_pole textarea_compact" rows="7"></textarea>
66 <div id="diceroll_options_prompt_restore" class="menu_button menu_button_icon">
67 <i class="fa-solid fa-clock-rotate-left"></i>
68 <span data-i18n="ext_diceroll_restore">Restore default</span>
69 </div>
70
71 <label for="diceroll_direction_template" data-i18n="ext_diceroll_direction_template">Direction message template (\{{outcome}}, \{{probability}} available)</label>
72 <textarea id="diceroll_direction_template" class="text_pole textarea_compact" rows="4"></textarea>
73 <div id="diceroll_direction_template_restore" class="menu_button menu_button_icon">
74 <i class="fa-solid fa-clock-rotate-left"></i>
75 <span data-i18n="ext_diceroll_restore">Restore default</span>
76 </div>
77 </div>
78 </div>
79</div>
public/scripts/extensions/diceroll/style.css+42 -0
@@ -0,0 +1,42 @@
1.diceroll_debug {
2 margin-top: 5px;
3 padding: 5px 10px;
4 border: 1px dashed var(--SmartThemeBorderColor, #888);
5 border-radius: 5px;
6 font-size: calc(var(--mainFontSize) * 0.85);
7 opacity: 0.85;
8}
9
10.diceroll_debug summary {
11 cursor: pointer;
12}
13
14.diceroll_debug_table {
15 border-collapse: collapse;
16 margin: 5px 0;
17 width: 100%;
18}
19
20.diceroll_debug_table td {
21 padding: 1px 8px 1px 0;
22 vertical-align: top;
23 border: none;
24}
25
26.diceroll_debug_table td:first-child {
27 text-align: right;
28 white-space: nowrap;
29 font-variant-numeric: tabular-nums;
30 width: 1%;
31}
32
33.diceroll_debug_table tr.diceroll_chosen {
34 font-weight: bold;
35 color: var(--SmartThemeQuoteColor, inherit);
36}
37
38.diceroll_debug_direction {
39 white-space: pre-wrap;
40 font-style: italic;
41 margin-top: 4px;
42}