feat(panel): cascading columns, WB colour swatches, stronger HDF glow
Layout - the panel is a cascade of columns: the rail's tabs, the tab's chips, the open chip's sub-chips, then the ruler. A child column no longer hides the column it came from (TEMP -> COLOR TEMP keeps TEMP visible); chips stack one per row instead of wrapping - FRAME's WATERMARK opens its own column, so the frame chips stay put - CREATE RECIPES gets the wide column its two-up form needs WB colour swatches - the ruler draws a colour box under the slider that follows the value: COLOR TEMP is the Kelvin colour (Tanner Helland), TINT runs green -10 -> neutral 0 -> magenta +10 HDF EFFECT - knee 0.55..0.85 -> 0.45..0.75, blur 0.004+0.015n -> 0.006+0.024n of the width, screen alpha 0.15+0.35n -> 0.28+0.52n: a wide halo on the highlights instead of a hairline glow. Web copy of toneShader only — the phone keeps its own tuning. Tabs - rail order is PRESETS, FAVORITED, WB, LIGHT, FX, FRAME, CREATE RECIPES
This commit is contained in:
@@ -118,10 +118,12 @@ vec4 main(vec2 xy) {
|
||||
// are untouched) and above it every channel keeps its ratio — the hue cannot
|
||||
// drift, only the brightness blooms.
|
||||
//
|
||||
// Knee t0..t1 = 0.55..0.85: the soft end of the old hard 0.6 cut (2.5*0.6-1.5 =
|
||||
// 0), wide enough that a specular ramps in instead of clipping on/off.
|
||||
export const GLOW_T0 = 0.55;
|
||||
export const GLOW_T1 = 0.85;
|
||||
// Knee t0..t1 = 0.45..0.75. The web demo plays the effect up, so the knee sits
|
||||
// lower and wider than the phone's 0.55..0.85: the bloom now catches the bright
|
||||
// end of the midtones (a lit face, a window) instead of only true speculars,
|
||||
// which is what makes it read on a photo that has no blown white.
|
||||
export const GLOW_T0 = 0.45;
|
||||
export const GLOW_T1 = 0.75;
|
||||
|
||||
export const GLOW_SKSL = `
|
||||
uniform shader src;
|
||||
|
||||
+102
-50
@@ -64,6 +64,37 @@ const TEMP_PRESETS: { label: string; kelvin: number }[] = [
|
||||
{ label: 'FLUOR', kelvin: 4000 },
|
||||
];
|
||||
|
||||
// Kelvin -> sRGB (Tanner Helland's approximation, what every WB widget uses):
|
||||
// 2500K reads warm orange, 5500K a near-neutral white, 10000K blue.
|
||||
function kelvinToHex(k: number): string {
|
||||
const t = Math.min(40000, Math.max(1000, k)) / 100;
|
||||
const chan = (v: number) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, '0');
|
||||
const r = t <= 66 ? 255 : 329.698727446 * (t - 60) ** -0.1332047592;
|
||||
const g = t <= 66 ? 99.4708025861 * Math.log(t) - 161.1195681661 : 288.1221695283 * (t - 60) ** -0.0755148492;
|
||||
const b = t >= 66 ? 255 : t <= 19 ? 0 : 138.5177312231 * Math.log(t - 10) - 305.0447927307;
|
||||
return `#${chan(r)}${chan(g)}${chan(b)}`;
|
||||
}
|
||||
|
||||
// HSL -> #rrggbb (CSS keeps hsl() opaque to a test, and the swatch prints its
|
||||
// own hex, so the panel hands out one format).
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
const k = (n: number) => (n + h / 30) % 12;
|
||||
const a = (s / 100) * Math.min(l / 100, 1 - l / 100);
|
||||
const chan = (n: number) =>
|
||||
Math.max(0, Math.min(255, Math.round(255 * (l / 100 - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))))))
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
return `#${chan(0)}${chan(8)}${chan(4)}`;
|
||||
}
|
||||
|
||||
// The knobs that drive a colour get a swatch under their ruler, so the hue is
|
||||
// visible next to the number. TINT runs green (-10) through a neutral grey (0)
|
||||
// to magenta (+10) — the axis every WB tool labels G..M.
|
||||
const SLIDER_SWATCH: Partial<Record<string, (v: number) => string>> = {
|
||||
temperature: kelvinToHex,
|
||||
tint: (v) => hslToHex(140 + ((v + 10) / 20) * 160, Math.abs(v) * 7, 58 + (10 - Math.abs(v)) * 3),
|
||||
};
|
||||
|
||||
// The option strips, one open at a time (phone's GroupKey set minus the groups
|
||||
// the web engine has no knob for).
|
||||
type GroupKey = 'cx' | 'cxb' | 'dr' | 'crop' | 'rotate' | 'style' | 'temp' | 'wm'
|
||||
@@ -664,13 +695,18 @@ export function Workspace() {
|
||||
}, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, place, user, t]);
|
||||
|
||||
// --- strips ---------------------------------------------------------------
|
||||
// One row at a time, exactly the phone's rule: opening a ruler closes the
|
||||
// option strip (and vice-versa), and either closes the expanded recipes.
|
||||
// The cascade: a chip opens the next column, and the column it came from stays
|
||||
// put (TEMP -> COLOR TEMP leaves TEMP visible with the ruler beside it). The
|
||||
// recipes list is the one exception — it belongs to PRESETS, not to a chip, so
|
||||
// anything else opening closes it.
|
||||
const toggleParam = useCallback(
|
||||
(key: string) => {
|
||||
setOpenGroup(null);
|
||||
const close = openParam === key;
|
||||
setOpenParam(close ? null : key);
|
||||
setShowRecipes(false);
|
||||
setOpenParam(openParam === key ? null : key);
|
||||
// Opening a ruler clears a strip from another branch; closing one leaves
|
||||
// its own strip standing, so the column the ruler came from is still there.
|
||||
if (!close) setOpenGroup(PARAM_GROUP[key] ?? null);
|
||||
},
|
||||
[openParam]
|
||||
);
|
||||
@@ -679,12 +715,13 @@ export function Workspace() {
|
||||
(g: GroupKey) => {
|
||||
const next = openGroup === g ? null : g;
|
||||
if (next) {
|
||||
setOpenParam(null);
|
||||
// A ruler only survives if it belongs to the strip being opened.
|
||||
if (PARAM_GROUP[openParam ?? ''] !== next) setOpenParam(null);
|
||||
setShowRecipes(false);
|
||||
}
|
||||
setOpenGroup(next);
|
||||
},
|
||||
[openGroup]
|
||||
[openGroup, openParam]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -715,15 +752,17 @@ export function Workspace() {
|
||||
const all = [...PARAM_DEFS.iq, ...PARAM_DEFS.wb, ...PARAM_DEFS.filters];
|
||||
const def = all.find((d) => d.key === openParam);
|
||||
if (!def) return null;
|
||||
const value = def.get(recipe.adjustments);
|
||||
return {
|
||||
key: def.key,
|
||||
label: def.label,
|
||||
value: def.get(recipe.adjustments),
|
||||
value,
|
||||
min: def.min,
|
||||
max: def.max,
|
||||
step: def.step,
|
||||
display: def.display(def.get(recipe.adjustments)),
|
||||
edited: def.get(recipe.adjustments) !== def.defaultValue,
|
||||
display: def.display(value),
|
||||
edited: value !== def.defaultValue,
|
||||
swatch: SLIDER_SWATCH[def.key]?.(value),
|
||||
onChange: (value) => setAdjustmentOnce(def.set(value)),
|
||||
// Back reopens the strip the row was launched from (COLOR TEMP -> TEMP).
|
||||
onBack: () => {
|
||||
@@ -1002,9 +1041,9 @@ export function Workspace() {
|
||||
case 'fx':
|
||||
return paramChips(PARAM_DEFS.filters);
|
||||
case 'frame':
|
||||
return isWmPanel(openGroup)
|
||||
? [{ key: 'wm-back', label: '< FRAME', onClick: () => setOpenGroup(null) }, ...wmChips()]
|
||||
: framesRow();
|
||||
// WATERMARK is a chip like the rest: its own sub-panel opens as the next
|
||||
// column (see the wm column in the render), so the frame chips stay put.
|
||||
return framesRow();
|
||||
// CREATE draws its own form (ui/RecipeCreatePanel) instead of a chip row.
|
||||
case 'create':
|
||||
return [];
|
||||
@@ -1045,6 +1084,12 @@ export function Workspace() {
|
||||
})),
|
||||
];
|
||||
|
||||
// The panel is a cascade of columns (see styles/app.css): the tab's chips, the
|
||||
// open chip's own panel, the open group's options, the open ruler. Each level
|
||||
// is a column of its own, so a child never hides the column it came from.
|
||||
const wmOpen = isWmPanel(openGroup);
|
||||
const optGroup = openGroup && openGroup !== 'wm' ? openGroup : null;
|
||||
|
||||
const status = busy;
|
||||
|
||||
return (
|
||||
@@ -1069,39 +1114,33 @@ export function Workspace() {
|
||||
<ToolRail tab={tab} onTab={setTab} />
|
||||
|
||||
<div className="chips">
|
||||
{/* 1. ruler for the open slider */}
|
||||
{slider ? <SliderRow slider={slider} /> : null}
|
||||
{/* column 1 — the tab's own chips, RESET pinned at its head */}
|
||||
<div className={`col col-main${tab === 'create' ? ' col-create' : ''}`} data-col="main">
|
||||
<ChipRow chips={[resetChip()]} />
|
||||
{tab === 'create' ? (
|
||||
<RecipeCreatePanel
|
||||
currentBase={recipe.baseFilter}
|
||||
current={recipe.adjustments}
|
||||
signedIn={!!user}
|
||||
onPreview={previewCreatedRecipe}
|
||||
onSave={(draft) => void addCreatedRecipe(draft)}
|
||||
onExport={exportRecipeFile}
|
||||
/>
|
||||
) : (
|
||||
<ChipRow chips={chips} />
|
||||
)}
|
||||
{tab === 'favorited' && saved.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
|
||||
</div>
|
||||
|
||||
{/* 2. the open group's options */}
|
||||
{openGroup && openGroup !== 'wm' ? (
|
||||
<div className="strip">
|
||||
<ChipRow chips={stripChips(openGroup)} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 3. PRESETS -> RECIPES strip */}
|
||||
{tab === 'presets' && showRecipes ? (
|
||||
<div className="strip">
|
||||
{recipeStrip.length === 1 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
|
||||
<ChipRow chips={recipeStrip} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 4. CREATE -> the phone's recipe form */}
|
||||
{tab === 'create' ? (
|
||||
<RecipeCreatePanel
|
||||
currentBase={recipe.baseFilter}
|
||||
current={recipe.adjustments}
|
||||
signedIn={!!user}
|
||||
onPreview={previewCreatedRecipe}
|
||||
onSave={(draft) => void addCreatedRecipe(draft)}
|
||||
onExport={exportRecipeFile}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* 5. FRAME -> WATERMARK sub-panel: the mark's own text + place */}
|
||||
{tab === 'frame' && isWmPanel(openGroup) ? (
|
||||
<div className="strip wm-extra">
|
||||
{/* column 2 — FRAME's WATERMARK chip opens the mark's own panel */}
|
||||
{wmOpen ? (
|
||||
<div className="col col-sub strip wm-extra" data-col="wm">
|
||||
<ChipRow
|
||||
chips={[
|
||||
{ key: 'wm-back', label: '< FRAME', onClick: () => setOpenGroup(null) },
|
||||
...wmChips(),
|
||||
]}
|
||||
/>
|
||||
{markOn ? (
|
||||
<input
|
||||
className="field"
|
||||
@@ -1151,12 +1190,25 @@ export function Workspace() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 6. main chip row, RESET pinned outside the scroll */}
|
||||
<div className="main-row">
|
||||
<ChipRow chips={[resetChip()]} />
|
||||
<ChipRow chips={chips} />
|
||||
</div>
|
||||
{tab === 'favorited' && saved.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
|
||||
{/* column 3 — the open chip's sub-chips, or PRESETS' RECIPES list */}
|
||||
{optGroup ? (
|
||||
<div className="col col-sub strip" data-col="options">
|
||||
<ChipRow chips={stripChips(optGroup)} />
|
||||
</div>
|
||||
) : null}
|
||||
{tab === 'presets' && showRecipes ? (
|
||||
<div className="col col-sub strip" data-col="recipes">
|
||||
{recipeStrip.length === 1 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
|
||||
<ChipRow chips={recipeStrip} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* column 4 — the open ruler, sliders stacked */}
|
||||
{slider ? (
|
||||
<div className="col col-slider" data-col="slider">
|
||||
<SliderRow slider={slider} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ImageStage
|
||||
|
||||
@@ -375,10 +375,12 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
drawBlurred(canvas, surface, width, height, (Math.abs(sharpKnob) / 10) * 3);
|
||||
}
|
||||
|
||||
// 5b. HDF bright-pass bloom.
|
||||
// 5b. HDF bright-pass bloom. The web demo shows the effect off, so both the
|
||||
// blur radius and the screen alpha push further than the phone's: a wide
|
||||
// halo around the highlights instead of a hairline glow.
|
||||
if ((adjustments.hdf ?? 0) > 0) {
|
||||
const strength = (adjustments.hdf ?? 0) / 10;
|
||||
const sigma = width * (0.004 + 0.015 * strength);
|
||||
const sigma = width * (0.006 + 0.024 * strength);
|
||||
const { glowEffect: effect } = effects();
|
||||
const srcShader =
|
||||
paintShader ??
|
||||
@@ -389,7 +391,7 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
effect != null ? own(effect.makeShaderWithChildren(glowUniformArray(), [srcShader])) : null;
|
||||
const glowPaint = own(Skia.Paint());
|
||||
glowPaint.setBlendMode(Skia.BlendMode.Screen);
|
||||
glowPaint.setAlphaf(0.15 + 0.35 * strength);
|
||||
glowPaint.setAlphaf(0.28 + 0.52 * strength);
|
||||
if (glowShader != null) {
|
||||
glowPaint.setShader(glowShader);
|
||||
glowPaint.setImageFilter(
|
||||
|
||||
@@ -62,27 +62,41 @@
|
||||
.rail button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-line); }
|
||||
.rail button:hover { color: var(--text); }
|
||||
|
||||
/* --- chip column -------------------------------------------------------- */
|
||||
/* --- panel columns ------------------------------------------------------- */
|
||||
/* The panel is a cascade: the rail holds the tabs, then one column per open
|
||||
level — the tab's chips, the open chip's sub-chips, the open ruler. A child
|
||||
column never hides the column it came from, so the path reads left to right. */
|
||||
.chips {
|
||||
width: var(--chip-w);
|
||||
flex: 0 0 auto;
|
||||
max-width: 62vw;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
border-right: 1px solid var(--border-soft);
|
||||
background: var(--bg);
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.col {
|
||||
width: var(--col-w);
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--border-soft);
|
||||
}
|
||||
.chips h2 { margin: 0 0 2px; font-size: 11px; letter-spacing: 0.14em; color: var(--text-dim); font-weight: 600; }
|
||||
.col:last-child { border-right: none; }
|
||||
.col-slider { width: var(--slider-w); }
|
||||
/* CREATE RECIPES draws a two-up form, so its column is the wide one. */
|
||||
.col-create { width: 300px; }
|
||||
.col-sub { background: var(--bg-elev); }
|
||||
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
/* One strip at a time: the ruler, the open group's options, the recipe list and
|
||||
the watermark fields each sit on their own row above the main chip row. */
|
||||
.strip { border-top: 1px solid var(--border-soft); padding-top: 8px; display: flex; flex-direction: column; gap: 8px; }
|
||||
/* RESET stays pinned at full size; the tab's chips take the rest and scroll. */
|
||||
.main-row { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.main-row > .chip-row:last-child { flex: 1 1 auto; min-width: 0; }
|
||||
.wm-extra { border-top-color: var(--accent-line); }
|
||||
/* In a column the chips stack: one chip per row, full width. */
|
||||
.col .chip-row { flex-direction: column; align-items: stretch; }
|
||||
.col .chip { justify-content: space-between; text-align: left; }
|
||||
.wm-extra { border-right-color: var(--accent-line); }
|
||||
.chip {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elev);
|
||||
@@ -94,6 +108,7 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.col-sub .chip { background: var(--bg); }
|
||||
.chip[aria-pressed="true"], .chip.on { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
|
||||
/* Edited value, strip closed: the text glows amber, the border does not. */
|
||||
.chip.amber, .chip.amber .val { color: var(--accent); }
|
||||
@@ -101,14 +116,36 @@
|
||||
.chip .pro { font-size: 9px; letter-spacing: 0.08em; color: var(--accent); }
|
||||
.chip:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* The ruler row: "<" back button, label + live value, full-width slider. */
|
||||
/* The ruler column: "<" back button, label + live value, slider, colour swatch
|
||||
for the knobs that drive a hue (WB's TEMPERATURE / TINT). */
|
||||
.slider-row { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; display: flex; align-items: center; gap: 10px; }
|
||||
.col .slider-row { flex-direction: column; align-items: stretch; gap: 6px; }
|
||||
.slider-row .ruler { flex: 1 1 auto; min-width: 0; }
|
||||
.slider-row .head { display: flex; justify-content: space-between; font-size: 11px; letter-spacing: 0.1em; color: var(--text-dim); }
|
||||
.slider-row .head b { color: var(--text); font-family: var(--mono); letter-spacing: 0; }
|
||||
.slider-row .head b.amber { color: var(--accent); }
|
||||
.slider-row .swatch {
|
||||
height: 26px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.slider-row .swatch span {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
color: #fff;
|
||||
mix-blend-mode: difference;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.slider-row .back {
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elev);
|
||||
color: var(--text-dim);
|
||||
@@ -299,7 +336,18 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
padding: 4px;
|
||||
}
|
||||
.rail button { flex: 1 0 auto; min-width: 62px; }
|
||||
.chips { order: 1; width: auto; border-right: none; border-bottom: 1px solid var(--border-soft); max-height: 42vh; }
|
||||
/* The cascade still runs left to right; the columns themselves scroll rather
|
||||
than the page, so the stage keeps its width. */
|
||||
.chips {
|
||||
order: 1;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
max-height: 42vh;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.col { width: 148px; padding: 8px; }
|
||||
.col-slider { width: 200px; }
|
||||
.col-create { width: 260px; }
|
||||
.stage { order: 0; flex: 1 1 auto; }
|
||||
.chip-row { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 4px; }
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
--radius: 10px;
|
||||
--radius-sm: 7px;
|
||||
--rail-w: 84px;
|
||||
--chip-w: 300px;
|
||||
--col-w: 168px;
|
||||
--slider-w: 232px;
|
||||
--font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-heading: var(--font);
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
|
||||
@@ -27,6 +27,9 @@ export interface SliderDef {
|
||||
display: string;
|
||||
// amber value readout once the knob leaves its default (phone's ruler row)
|
||||
edited: boolean;
|
||||
// CSS colour the knob is currently driving (WB's temperature/tint), drawn as a
|
||||
// swatch under the ruler so the hue moves with the number. Absent = no swatch.
|
||||
swatch?: string;
|
||||
onChange: (value: number) => void;
|
||||
// "<" — closes the ruler and reopens the strip it was launched from.
|
||||
onBack: () => void;
|
||||
@@ -90,6 +93,17 @@ export function SliderRow({ slider }: { slider: SliderDef }) {
|
||||
value={slider.value}
|
||||
onChange={(e) => slider.onChange(Number(e.target.value))}
|
||||
/>
|
||||
{slider.swatch ? (
|
||||
<div
|
||||
className="swatch"
|
||||
data-key={`swatch-${slider.key}`}
|
||||
data-color={slider.swatch}
|
||||
style={{ background: slider.swatch }}
|
||||
title={slider.swatch}
|
||||
>
|
||||
<span>{slider.swatch}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import type { MsgKey } from '../i18n/vi';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
|
||||
// The app's six tabs, in the same order as the phone rail. The rail is vertical
|
||||
// on desktop and becomes the bottom bar under 860px (see styles/app.css).
|
||||
export type TabId = 'presets' | 'favorited' | 'light' | 'wb' | 'fx' | 'frame' | 'create';
|
||||
// The app's seven tabs. WB leads LIGHT — white balance is the first thing the
|
||||
// eye corrects, and the web rail draws them in the order the panel opens. The
|
||||
// rail is a single column on desktop and becomes the bottom bar under 860px
|
||||
// (see styles/app.css).
|
||||
export type TabId = 'presets' | 'favorited' | 'wb' | 'light' | 'fx' | 'frame' | 'create';
|
||||
|
||||
export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [
|
||||
{ id: 'presets', label: 'tab.presets', glyph: '◉' },
|
||||
{ id: 'favorited', label: 'tab.favorited', glyph: '★' },
|
||||
{ id: 'light', label: 'tab.light', glyph: '☀' },
|
||||
{ id: 'wb', label: 'tab.wb', glyph: '◐' },
|
||||
{ id: 'light', label: 'tab.light', glyph: '☀' },
|
||||
{ id: 'fx', label: 'tab.fx', glyph: '✦' },
|
||||
{ id: 'frame', label: 'tab.frame', glyph: '▣' },
|
||||
{ id: 'create', label: 'tab.create', glyph: '✎' },
|
||||
|
||||
Reference in New Issue
Block a user