mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-11-01 19:20:40 +00:00
aec0a51b1f44b5dfd7f8ea125cadc44d98c97cf2
265
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
aec0a51b1f |
PPTX chart import Phase 4b: data labels
Native rendering of c:dLbls — showVal / showCatName / showPercent /
showSerName flags. Series-level dLbls overrides chart-type-level
when both are present (matches PPTX inheritance rules).
Shared helpers:
- _readDataLabelOpts(dLblsNode): parse c:showVal / c:showCatName /
c:showPercent / c:showSerName / c:dLblPos into a plain object.
Returns null when the node is missing.
- _resolveDataLabelOpts(serNode, chartTypeNode): merge series-level
over chart-type-level; returns null when no show* flag is set.
- _formatDataLabelText(opts, value, cat, total, name): join the
enabled fields with \n (becomes <br> in the emitted HTML).
showPercent uses Math.round(value/total*100).
Per-chart-type emission:
- Bar / column: text layer above each column ("outEnd"-like
default position), or to the right of each horizontal bar.
Reuses the bar-coord math; clamps to plot area so labels above
the topmost bar don't escape upward.
- Pie / doughnut: text layer at the slice's angular midpoint,
positioned 65 % of the way from centre to rim. Single-series
c:ser so the merged opts apply to every slice.
- Line: text layer above each data point. Emitted in the marker
loop so it's positioned even when markers themselves are
disabled.
User-visible: gallery_template_layout_set.pptx slide 10's chart
has three c:dLbls blocks (one chart-level, two series-level) all
with showVal=0 by default — labels stay hidden unless a series
explicitly enables a flag. Decks that ship showVal=1 (the more
common configuration) now get readable numeric labels above each
bar / pie slice / line point.
Regression: polyline path test still passes.
|
||
|
|
1bfae87022 |
PPTX chart import Phase 4a: title + legend across all chart types
The "chart polish" layer that surrounds every chart type — title text and series legend with colour swatches — now lands natively instead of being silently dropped. Shared helpers (used by all three builders): - _readChartTitle(chartDoc): walks c:chart/c:title/c:tx/c:rich and joins a:p paragraphs + a:r runs. Returns '' when no title or when c:autoTitleDeleted="1" (the common "auto title suppressed" case for templates). - _readChartLegend(chartDoc): reads c:chart/c:legend/c:legendPos@val (PPTX values 'r' / 'l' / 't' / 'b' / 'tr'; default 'r' when legend is present without an explicit position). - _computePlotInsets(chartDoc, hasAxes): returns inset fractions for the plot area, growing the right (or left / top / bottom) inset to make room for the legend, and the top inset to make room for a title. hasAxes adds a 15 % bottom strip + 8 % left strip for category labels / value-axis tick labels (used by bar / line, not pie). - _plotAreaFromInsets(gfBounds, insets): apply fractions. - _buildTitleLayer: emit one centred-top text layer. - _buildLegendLayers: emit (colour-rect + name-text) pair per series. Right / top-right stack vertically along the right edge; left mirrors; top / bottom flow horizontally. Each chart builder now: - Takes chartDoc as a parameter (dispatch in convertChart passes it through). - Uses the shared inset / plot-area helpers (replaces the inline hand-rolled 5 % / 15 % math). - Appends title + legend layers after its own slices / bars / lines. Pie chart legend lists slice CATEGORIES (each in its own slice colour) rather than series names — a single c:ser carries N visual categories that the user thinks of as "series" in PowerPoint. Verified against the user's gallery_template_layout_set.pptx slide 10: the chart has a c:legend block (and no title — flagged autoTitleDeleted=1). After this commit the legend swatches + "Baseline" / "Target" labels appear on the right side of the chart in the imported deck. Pure-Node polyline test still passes (no regression on Phase 3). |
||
|
|
5360186384 |
PPTX chart import Phase 3: line charts with optional markers
c:lineChart now renders as native Artstr layers — one
kind:'path' polyline layer per series stroked in the series
colour, plus optional circle marker layers at each data point,
plus category-label text layers below the plot area.
Mechanics:
- _polylinePathD(xs, ys) builds an SVG "M x0 y0 L x1 y1 …" string
in 0..100 viewBox coords with 3-decimal rounding. Single point
emits a degenerate M+L for visibility; empty arrays return ''.
- buildLineChartLayers:
- Collects categories from the first series, walks all series
for { name, values, color, lineWidth, marker }.
- Series colour comes from c:ser/c:spPr/a:ln/a:solidFill (the
line stroke, NOT the spPr.solidFill that bar charts use).
Falls back to CHART_DEFAULT_PALETTE by series index.
- Line width from a:ln@w in EMU, converted via EMU_PER_IN
directly (stroke widths are absolute in PPTX, not scaled
with the slide). Default ~2 CSS px when unset.
- Markers from c:marker. symbol != 'none' enables them;
size in points from c:marker/c:size (default 5pt); marker
fill from c:marker/c:spPr/a:solidFill overriding the line
colour when present. All marker symbols approximate as
circles for now (square / triangle / diamond / star is a
Phase 4 polish).
- Plot area uses the same 5 % / 15 % inset model as the bar
chart so axes line up visually if both chart types appear
on the same slide.
- One path layer per series covers the whole plot area; the
polyline d uses (i / (N-1)) · 100 for x and
100 · (1 - v / maxV) for y. Single-category charts collapse
to centre.
- Marker layers are square-bounded circles centred on each
data point.
- Category labels emit centred under each data-point x, like
the bar-chart category labels.
- convertChart dispatch grew a fourth branch (lineChart). The
"common three" chart types are now all native.
Pure-Node unit test for _polylinePathD covers empty / single /
two / four-point / zigzag / fractional-rounding cases. All
passing.
|
||
|
|
21a5668e45 |
PPTX chart import Phase 2: pie + doughnut slice paths
c:pieChart and c:doughnutChart now emit native Artstr path-shape
layers — one per slice — instead of the placeholder image.
Mechanics:
- _sliceArcPath(cx, cy, outerR, startAngle, endAngle, innerR) builds
the SVG d-attribute for a single slice. For pies it emits
M cx cy L start A r r 0 large 1 end Z. For doughnuts (innerR>0)
it emits M outerStart A outerArc L innerEnd A innerArc-back Z —
a proper ring sector. Full-circle case (sweep ≈ 2π) emits two
half-arcs so SVG can render it; full-ring case adds the inner
pair.
- _readPieSliceColor honours PPTX precedence:
1. c:ser/c:dPt[idx=N]/c:spPr/a:solidFill (per-slice override)
2. c:varyColors val="1" (default for pies) → palette rotation
indexed by sliceIdx using the existing CHART_DEFAULT_PALETTE
3. c:ser/c:spPr/a:solidFill (uniform pie)
4. Palette fallback as last resort.
- buildPieChartLayers picks the first c:ser, sums values for the
total, reads holeSize (for doughnuts, default 50 %), reads
firstSliceAng (default 0 = 12 o'clock), centres a square
sub-bounds inside the chart's graphicFrame so the pie renders
circular even on rectangular chart areas, then walks slices
clockwise emitting one path-shape layer per non-zero value.
- convertChart dispatch grew two branches (pieChart, doughnutChart)
that route into buildPieChartLayers with the isDoughnut flag.
Layer naming: "Chart 1 / Slice [Sales] (37)" so each slice in the
layer panel surfaces its category + value.
Pure-Node unit test for _sliceArcPath covers 14 cases — quarter
slice, major arc, doughnut ring sector, zero / negative / full
circle, full ring. All passing. Browser smoke test deferred until
CDP is available.
|
||
|
|
5588020812 |
PPTX chart import Phase 1: native bar / column rendering
Replaces the chart placeholder image with native Artstr layers for
the most common chart type — c:barChart in clustered grouping,
either col (vertical) or bar (horizontal) direction. The user's
gallery_template_layout_set.pptx slide 10 chart now produces 8
rect shape layers in #5E735D + #B05A44 plus 4 Q1-Q4 category-
label text layers instead of a single placeholder image.
What's in this commit (CHART_IMPORT_FEATURE.md §11 Phase 1):
- Cache-extraction helpers, no DOM-walking surprises:
readCategoryLabels(catNode) — c:strRef / c:multiLvlStrRef →
string[] (collapses multi-level to the deepest level).
readSeriesValues(valNode) — c:val/c:numRef/c:numCache →
number[], sparse indices filled with 0.
readSeriesName(txNode) — c:tx/c:strRef/c:strCache → string.
readSeriesColor(serNode, theme, fallbackIdx) — explicit
c:ser/c:spPr/a:solidFill (srgbClr or schemeClr via theme),
falling back to a built-in 6-colour Office palette.
- buildBarChartLayers(barChartNode, gfBounds, chartLabel, ctx):
- Reads series + categories + values from caches.
- Plot area = chart bounds minus 5 % left/right inset and 15 %
bottom inset for category labels (manualLayout in Phase 4).
- For c:barDir val="col": catW = pa.w / N, S bars per category,
each rect height = pa.h · value / maxValue.
- For c:barDir val="bar": axes swapped.
- Layer names: "Chart 1 / Bar [Q1, Baseline]" so the user can
trace each rect back to its source data.
- Category labels emitted below (col) or left of (bar) the plot
area as text layers, 12pt centred / right-aligned.
- Stacked / percentStacked groupings return null → fall through
to placeholder (Phase 5).
- convertChart(chartDoc, gfBounds, chartLabel, ctx) dispatch reads
the first chart-type child of c:plotArea and routes:
barChart → buildBarChartLayers
others → null (placeholder fallback)
- convertGraphicFrame wired to try native chart conversion before
emitting the placeholder. On success returns the layer array
and bumps report.imported.{shapes,text}; on null returns the
placeholder + UNSUPPORTED_CHART_PLACEHOLDER warning (existing
behaviour) OR if the chart XML was readable but type wasn't
supported, emits the new CHART_TYPE_UNSUPPORTED warning.
- ctx now carries files + slidePath so
_readChartDocFromGraphicFrame can chase the c:chart r:id through
slideRels to the chart XML in ppt/charts/chartN.xml and parse it.
- convertGraphicFrame now always returns an array (single-element
for the placeholder path, N-element for native charts). walkSpTree
updated to spread the result.
- New internals exposed for future tests: readCategoryLabels,
readSeriesValues, readSeriesName, readSeriesColor,
buildBarChartLayers, convertChart.
Browser smoke test still needed — pure-Node testing was blocked by
no @xmldom/xmldom on the system and the existing CDP-driven
harness needs Chrome with --remote-debugging-port=9222. Syntax
check + rich-text unit test both still pass.
|
||
|
|
5422f2c3e8 |
Draft PPTX chart-import spec
Spec for the next big arc: replacing the current chart-placeholder behaviour (single image layer at PPTX_IMAGE_PLACEHOLDER_URL) with native rendering — bars / wedges / lines as Artstr shape layers, labels as text layers. Branch pptx-chart-spec. Scope decisions baked in: - Phase 1: clustered column + bar charts only. Bars as rect shape layers, category labels as text. Plot area derived via a fixed inset on the chart's graphicFrame bounding box (no c:manualLayout reading until Phase 4). - Phase 2: pie / doughnut. Slice geometry emitted as kind:'path' with SVG arc d-attributes. - Phase 3: line charts with optional markers. - Phase 4: title / legend / data labels / value-axis ticks / gridlines. - Phase 5: stacked + percent-stacked variants. - Deferred to placeholder: scatter, bubble, area, radar, 3D, combo charts, trendlines, error bars, per-point overrides. Architecture: - New src/pptx-importer-charts.js with the per-type builders, loaded as an addendum to pptx-importer.js (or inlined — TBD during implementation; the dependency budget is small enough that a single file might be cleaner). - convertGraphicFrame in pptx-importer.js gets a "try native before placeholder" branch: parse chart XML, dispatch on type, fall through to the existing placeholder + warning when the builder returns null. - All chart data read from c:strCache / c:numCache. The embedded ppt/embeddings/*.xlsx workbook is ignored — caches are reliable in OOXML output and reading XLSX is its own rabbit hole. Acceptance criteria for Phase 1: gallery_template_layout_set.pptx slide 10 imports with 8 native bar layers (#5E735D + #B05A44 clustered across Q1-Q4) plus 4 category-label text layers, matching what PowerPoint shows. Scatter / 3D / etc. still placeholder cleanly without crash. |
||
|
|
3af3064013 |
Editor: lower .freeLayer min size so thin imported lines render true
A long-standing .freeLayer { min-width: .25in; min-height: .18in }
rule (probably set in the early days for usability — a 0px-tall
layer is unclickable) was silently enforcing a 17 CSS-pixel floor
on every layer in the editor. The thumbnail / preview renderer
uses a different class so it wasn't affected — that's why deck
thumbnails looked correct but the live editor inflated PPTX
hairlines from ~1.6 CSS pixels to ~17.
Drop the floor to 1px on each axis. Still enough to prevent a
zero-area layer collapse, but small enough that imported
thin-rect lines (and any other genuinely-thin shape) render at
their actual dimensions.
Native-tool layers won't go below the user's drag tolerance
anyway, so this change has no UX effect on hand-drawn shapes.
|
||
|
|
4608f8b190 |
PPTX import: render straight lines as thin rects for true thickness
The Artstr line renderer floors stroke-width at 0.5 viewBox units, which on a typical 5"-wide slide line bottoms out at ~2.5px — way too thick for a real hairline (PPTX line widths are often 0.7–1pt ≈ 1–1.4px at 96dpi). Even with my dynamic strokeWidth fix the floor was the binding constraint and lines came out 50–150% thicker than the PowerPoint source. Fix: for horizontal (cy=0) and vertical (cx=0) lines, switch the layer representation from kind:'line' to kind:'rect' with a solid fill of the source stroke colour and a bounding box sized to exactly the source a:ln@w in inches. The rect's height (for horizontal lines) or width (for vertical) IS the line thickness, so what you see is pixel-accurate to PowerPoint. The rect is centred on the original source line position (bounds.y - strokeWidth/2 for horizontal, bounds.x - strokeWidth/2 for vertical) so it doesn't visually drift. Diagonal lines keep kind:'line' — they can't be expressed as an axis-aligned rect — and continue to use the fill-as-stroke-colour quirk + (stroke.width / bounds.w) · 100 strokeWidth derivation, subject to the renderer's 0.5-unit floor. Editor implication: imported straight lines surface as "Rectangle (<source name>)" layers in the panel rather than "Line (<source name>)". Functionally identical for repositioning + recolouring; users who want a true line layer for further editing (dash style, arrow heads, etc.) can re-draw with the line tool. Future enhancement could attach a hint flag so the editor offers "convert back to line". Affects the 16 horizontal lines (slides 1–14, 16) and 4 vertical lines (slide 15) in gallery_template_layout_set.pptx. They now render at the exact PowerPoint thickness. |
||
|
|
4f2ec01016 |
PPTX import: flat horizontal / vertical lines, no more slight tilt
PPTX expresses a horizontal line as cy=0 in the bounding box (the
line has no vertical extent) and a vertical line as cx=0. My
previous default of corner-to-corner (0,0)→(100,100) viewBox
endpoints turned that degenerate axis — after clamping to 2 pixels
of layer thickness — into a 0.26° slope across the line length,
which read on screen as a slight tilt + anti-aliasing fuzz that
also made the stroke look thicker than it really was.
Fix: detect the source extents and route endpoints by orientation:
horizontal (cy=0, cx>0): x1=0, y1=50, x2=100, y2=50 (flat;
flipH swaps x).
vertical (cx=0, cy>0): x1=50, y1=0, x2=50, y2=100 (flat;
flipV swaps y).
diagonal (both > 0): corner-to-corner with flipH/flipV
swapping as before.
Also:
- Drop the min line thickness from 2px to 1px now that we don't
need extra room for an angled diagonal to live inside.
- Centre the degenerate-axis clamp on the source position so the
line stays exactly where PowerPoint put it (bounds.y -=
MIN/2 + bounds.h = MIN, instead of just extending in +y).
Affects the 16 horizontal lines (slides 1-14, 16) and 4 vertical
lines (slide 15) in gallery_template_layout_set.pptx. The
remaining thickness overhead vs source (~30-40%) is the
renderer's hard 0.5-viewBox-unit stroke-width floor; truly exact
hairlines would need a different shape representation (e.g. thin
filled rect) which is a future enhancement.
|
||
|
|
9a7a0841df |
PPTX import: derive line stroke width from a:ln@w
Imported lines were rendering ~25x thicker than their PowerPoint
source because shape.strokeWidth was left at the _LINE_DEFAULTS
value of 6 viewBox units. The Artstr line renderer treats that
viewBox stroke as 6% of the layer's width, so a 4.58"-wide line
shape produced a 0.27" tall "bar" instead of a hairline. The
shapes were correctly positioned — they just looked like big
filled bars because the strokes were huge.
Fix: derive shape.strokeWidth from the source a:ln@w. Math:
the renderer sets vbH = 100·lh/lw so both viewBox axes share a
uniform scale of (lw_inches / 100) inches per viewBox unit. So
to target an on-screen stroke equal to the source thickness in
inches, set:
shape.strokeWidth = (stroke.width_in_inches / lw_inches) · 100
The renderer's internal clamp to [0.5, 50] viewBox units acts as
a soft floor / ceiling for very wide or narrow lines (a 0.011"
hairline on a 5"-wide horizontal line bottoms out at ~0.5px
thick — visibly a line, just slightly thicker than source).
User-visible: re-importing gallery_template_layout_set.pptx now
yields lines at roughly their source thickness — the bottom-of-
slide #69746C hairlines look like hairlines, not bars.
|
||
|
|
29e731075e |
PPTX import: fix missing lines + add bent / curved connectors
Lines were getting silently dropped because their PPTX bounding boxes are degenerate (cy=0 for horizontal lines, cx=0 for vertical), and convertPresetShape's zero-area filter treated that as "skip". Lines need the filter to be kind-aware: a 0-thickness layer for a line is legitimate; for a rect it isn't. Three fixes shipped together: 1. Move the zero-area filter to after the shape-kind lookup, then exempt shape.kind === 'line' from it. The bounding box is then clamped to a 2-pixel minimum thickness (~2/96 in) on the degenerate axis so the Artstr renderer has a non-zero pixel container to draw the SVG into (a 0-height layer renders into a 0-height DOM box regardless of overflow:visible inside). 2. Quirk of Artstr's line renderer: the inner <line> uses the layer's `fill` paint as its stroke colour, not the layer's stroke field (see buildShapeSvgInner in index.html). PPTX puts the line colour on a:ln, so for shape.kind === 'line' the imported stroke colour is now copied into the layer fill. Lines previously came in with a transparent fill + a real stroke that the renderer ignored — i.e. invisible. Now they render the right colour. 3. Add bent / curved connectors (bentConnector2..5, curvedConnector 2..5) to PPTX_PRST_TO_ARTSTR as approximations of a straight line, with an UNSUPPORTED_CONNECTOR_APPROXIMATED warning so the user knows the joints / curves weren't preserved. The deck the user reported doesn't use these but other decks do. Pure-Node unit test for _runToHtml (added in the prior commit) still passes — the changes are confined to convertPresetShape. User-visible: the 16 lines in gallery_template_layout_set.pptx that were missing should now appear at the right positions with their actual colour (e.g. the bottom-of-slide hairline at #69746C). |
||
|
|
b6cf4f38eb |
PPTX import Phase 7: per-run rich text spans
Mid-paragraph styling now survives the import. Before this commit,
_readTxBody collapsed every paragraph to its first run's style; a
title like "Gallery Deck System" where one word was bold would
lose the bold. Now each run is emitted as HTML with inline
<b> / <i> / <span style="..."> wrappers, but only where it
disagrees with the layer-level dominant style — so plain runs
stay as plain text and the HTML stays clean.
Mechanics:
- _readParagraphRuns now returns an array of { text, style }
per a:r / a:fld (text-bearing) or a:br (synthetic '\n' run with
null style). null style means "inherit layer defaults" — the
HTML builder emits unwrapped escaped text for these.
- _runsToPlainText is the new helper for callers that only want
text (the notes-slide reader is the only one today).
- _runToHtml(run, dominant) builds the per-run HTML:
- inline span for font-size / color / font-family overrides
that disagree with the dominant; inner-most wrap.
- <i> / <span style="font-style:normal"> for italic deltas.
- <b> / <span style="font-weight:normal"> for bold deltas;
outermost wrap.
font-family values with spaces are escaped through " so
inner double-quotes can't terminate the style="..." attribute.
- _readTxBody picks the first explicit-rPr run anywhere in the
body as the dominant style, then emits per-paragraph segments
joined contiguously and paragraphs joined with <br>.
- The notes-slide reader uses _runsToPlainText to keep its plain-
text concatenation behaviour.
Verified with a pure-Node unit test of _runToHtml covering 10
cases: plain run, mid-paragraph bold, color override, font-family
with spaces (HTML-entity quoted), size + family + italic combo,
HTML escaping, embedded \n → <br>, null-style inheritance, the
reverse-override "not-bold inside bold dominant", and an empty
dominant. All passing.
Browser CDP integration test isn't in this commit (the dev
server / Chrome session was offline at test time). The refactor
is otherwise structural: _readTxBody returns the same shape, and
the layer-level dominantStyle still drives the layer's fontSize /
color / family / align / bold / italic fields, so existing decks
should produce the same layer-level output with strictly richer
per-run HTML.
|
||
|
|
24338c4f12 |
TODO: move PPTX import out of unbuilt, capture remaining follow-ups
Phases 0–6 of the PPTX import arc shipped on pptx-import (deck shell + slide backgrounds + text + notes + image / chart / table / SmartArt placeholders + preset shapes with fills/strokes + group flattening + theme color resolution + master/layout bg inheritance + line flip handling). Move the entry out of "Drafted-but-unbuilt features" and add a "PPTX import polish" section listing what's still open: - Phase 7 per-run rich text spans (today the first run's style applies to the whole text box; mid-paragraph styling is lost). - Richer import report modal (per-slide warning summary, group counts). - Native chart support — render OOXML chart XML into Artstr layers rather than placeholder images. Its own multi-phase arc. - Phase 6+ inheritance gaps: gradient / picture backgrounds, font scheme (+mj-lt → real font), placeholder geometry inheritance. - a:custGeom → custom SVG-path conversion for arbitrary shapes (currently fall back to rect + warning). - Test deck library under test/pptx/ to catch future-phase regressions automatically. - Eventual merge of pptx-import → main. |
||
|
|
38894031bf | Mark Phase 6 done in PPTX spec | ||
|
|
a448afc077 |
PPTX import Phase 6: theme colors + bg inheritance + line flips
Three related upgrades to fidelity: Theme color resolution: - loadThemeForSlide(files, slidePath) chases the OOXML inheritance chain (slide → slideLayout → slideMaster → theme) and parses the a:clrScheme into a name → hex map for accent1..6, dk1/2, lt1/2, hlink, folHlink. sysClr entries fall back to @lastClr. The map also honours the bg/tx → lt/dk aliases that PPTX uses for light themes. Themes are cached by zip path so a 50-slide deck sharing one theme reparses zero times after the first slide; the cache is cleared at the start of each importPptxFile. - convertColor(node, theme) now accepts a:srgbClr, a:schemeClr, and a:sysClr. schemeClr@val is resolved through the theme map. - After a base hex is chosen, _readColorModifiers + _applyColorModifiers apply lumMod / lumOff / tint / shade in HSL (PPTX values are in 1/1000 of a percent). Verified: accent1 (#ff0000) with lumMod 60000 → #990000 — matches PowerPoint's render. Background inheritance: - readSlideBackground now falls back through the slide → layout → master chain when the slide has no direct p:bg or only a p:bgRef. Theme is passed through so schemeClr-based backgrounds also resolve. Verified: a slide with no own bg correctly inherits the master's #abcdef. Line flip handling: - a:prstGeom prst="line" / "straightConnector1" go corner-to-corner of the bounding box. PowerPoint flips them via xfrm@flipH / @flipV; the Artstr line shape stores its segment endpoints in a 0-100 viewBox, so we set x1/y1/x2/y2 to the right diagonal at conversion time: no flip: (0,0)→(100,100) flipH: (100,0)→(0,100) flipV: (0,100)→(100,0) both: (100,100)→(0,0) Previously every line came in as a flat horizontal at y=50; now diagonals show up correctly. Plumbing: theme + groupXfrm + slideRels all live on ctx and are threaded through walkSpTree, the converters, readFill, readStroke, _readTxBody, _readParagraphRuns, _readRunStyle. Real-deck regression (gallery_template_layout_set.pptx): 16 slides, 156 text, 64 shapes, 1 chart placeholder, 0 groups — all counters identical to Phase 5. Synthetic theme-inheritance deck passes every check (bg inheritance, scheme color, lumMod modifier, line flip). |
||
|
|
1d0239f022 | Mark Phase 5 done in PPTX spec | ||
|
|
6a731d5d01 |
PPTX import Phase 5: flatten p:grpSp into slide-relative layers
Shapes nested inside p:grpSp were silently dropped in Phase 4 —
walkSpTree only knew about top-level sp / pic / graphicFrame
children. Phase 5 recurses into groups with a composed coordinate
transform so every child shape lands at its correct slide
position regardless of nesting depth.
Mechanics:
- IDENTITY_GROUP_XFRM { offX:0, offY:0, sX:1, sY:1 } is the
starting transform for shapes at the top of a slide.
- enterGroup(parent, groupXfrmNode) composes a new transform when
walking into a p:grpSp. PPTX semantics:
- a:off + a:ext give the group's position + size in the PARENT
child-coord space.
- a:chOff + a:chExt define the child-coord space INSIDE this
group (the coord system the group's direct children live in).
Composition first lifts the group itself into slide-EMU through
the parent transform, then derives scale factors so a child at
childExt = chExt maps onto groupSlideSize.
- readXfrm now accepts an optional groupXfrm (defaults to identity)
and converts child-EMU → slide-EMU → inches in one shot. All four
converters (preset / text / picture / graphicFrame) pass ctx.groupXfrm
through unchanged.
- walkSpTree dispatches p:grpSp by reading grpSpPr/a:xfrm, composing
the new transform via enterGroup, recursing into the group node
itself (groups host their children directly), then restoring the
prior transform on the way out. Sibling groups + top-level shapes
see the original transform — no leakage.
- report.imported.groups counts each p:grpSp entered (useful for
the import-report modal once we wire it).
Synthetic smoke test verifies the math exactly:
- Group at slide-EMU (1M, 1M) sized 4M x 2M with child-coord space
0..2000 x 0..1000.
- Child A at child-coord (0, 0) ext (1000, 500)
→ expected (1.64, 1.64) at 3.28 x 1.64 inches ✓
- Child B at child-coord (1000, 500) ext (1000, 500)
→ expected (4.92, 3.28) at 3.28 x 1.64 inches ✓
- Outside sibling at slide-EMU (8M, 5M) ext (1M, 500K)
→ expected (13.12, 8.20) at 1.64 x 0.82 inches ✓
(proves transform doesn't leak past the group)
Real-deck regression check (gallery_template_layout_set.pptx):
16 slides, 156 text layers, 64 shapes, 1 chart placeholder, 0
groups (this deck doesn't use groups) — counts identical to
Phase 3 output. No regression.
|
||
|
|
d355c21792 | Mark Phase 3 done in PPTX spec | ||
|
|
3237a69247 |
PPTX import Phase 3: image + chart / table / SmartArt placeholders
Every p:pic and every p:graphicFrame on a slide now becomes an Artstr image layer pointing at the shared placeholder URL (https://i.postimg.cc/9fY3n60q/example-image-replace-me.png). No binary media is ever decoded from the .pptx package — the user replaces each placeholder with their own URL after import, which keeps imported decks Nostr-publishable out of the box and honours Artstr's "remote URLs only" image model. Pictures (p:pic): - convertPicture reads spPr/xfrm for geometry, resolves the a:blip@r:embed relationship through the slide's _rels file, and uses the original media filename (basename of the relationship Target, URL-decoded) in the layer name: "Image: image3.png". - Missing embed relationship → MISSING_IMAGE_RELATIONSHIP warning; the placeholder still lands. GraphicFrames (p:graphicFrame): - convertGraphicFrame reads xfrm directly off graphicFrame (not spPr — that's a graphicFrame quirk), then dispatches on a:graphic/a:graphicData@uri: • chart → "Chart placeholder: <name>" + UNSUPPORTED_CHART_PLACEHOLDER + placeholders.charts++ • table → "Table placeholder: <name>" + UNSUPPORTED_TABLE_PLACEHOLDER + placeholders.tables++ • diagram → "SmartArt placeholder: <name>" + UNSUPPORTED_SMART_ART_PLACEHOLDER + placeholders.smartArt++ • else → "Unknown object placeholder" + UNSUPPORTED_UNKNOWN_PLACEHOLDER + placeholders.unknown++ walkSpTree now dispatches sp / pic / graphicFrame. readSlideRels loads the per-slide _rels file into a Map<rId, {target,type}> once per slide and passes it through ctx.slideRels. Smoke-tested: - gallery_template_layout_set.pptx: slide 10's chart now appears as 'Chart placeholder: Chart 0' (image layer, postimg URL, 9.3 x 5.85 inches at 1.2, 3.08) with an UNSUPPORTED_CHART_PLACEHOLDER warning in the report. - Synthetic multi-placeholder deck (1 picture + chart + table + SmartArt) exercises every branch: Image: image1.png from rels basename; chart / table / SmartArt placeholders with the right names and counters; positions and dimensions all correct in inches. |
||
|
|
8cf79f0fe0 | Mark Phase 2 done in PPTX spec | ||
|
|
49d6697660 |
PPTX import Phase 2: text shapes + speaker notes
Two paths land together because they share paragraph/run parsing. Text shapes (p:sp with p:txBody): - _readTxBody walks a:p paragraphs and a:r / a:fld runs, joining paragraph text with \n internally then escaping + converting to <br>-joined HTML on the way out. - _readRunStyle pulls the first run's a:rPr → fontSize (sz is hundredths-of-pt → pt), bold (b), italic (i), color (a:solidFill/a:srgbClr), and explicit font family (a:latin@typeface, but skip theme placeholders like '+mj-lt'). - a:pPr@algn → Artstr alignment via PPTX_ALIGN_TO_ARTSTR map (l/ctr/r/just). First paragraph's alignment wins for the whole box. - convertTextShape emits an Artstr text layer with the dominant style applied to the whole box; xfrm provides bounds. - Shapes without explicit xfrm (text inheriting placeholder geometry from a layout / master) warn TEXT_STYLE_APPROXIMATED and skip — proper placeholder resolution is Phase 6. - walkSpTree dispatches p:sp to convertTextShape if a p:txBody child is present, otherwise to convertPresetShape. Bumps report.imported.text or .shapes accordingly. Speaker notes: - readSlideNotes(files, slidePath, slideIndex, report) reads the slide's _rels/<name>.xml.rels, finds the .../notesSlide relationship, resolves the path, and walks the notesSlide's spTree concatenating every p:sp/p:txBody's plain text. - Filters out the slide-number stamp (PowerPoint embeds the current slide number as a "1" / "2" text on the notes-slide image placeholder; matching ^\d+$ drops it). - Result goes into slide.notes, which Presenter Mode already renders. report.imported.notes counts non-empty slides. Smoke-tested against gallery_template_layout_set.pptx (16 slides): - 156 text layers imported, zero warnings. - Slide 1 title 'Gallery\\nDeck System' lands as 'Gallery<br>Deck System' at 49pt Aptos Display bold #17202a. - Subtitle EDITORIAL TEMPLATE KIT lands at 8.5pt Aptos bold #b05a44 — exact source color. - 0 notes (this template-only deck doesn't ship with notes). Synthetic deck still produces an empty deck shell with no regressions. |
||
|
|
feace5e78c |
PPTX import: fix unit conversion — layer coords go to inches, not pixels
Phase 4 was emitting layer x/y/w/h in pixel units, but Artstr's canvas system (slide / customart / deck) treats layer coordinates as *inches* — it derives the on-screen canvas size by dividing slide.width by 96 (customArtCanvasInches() at line 6813). So a shape at x=1000, w=500 was being rendered as "1000 inches from the left, 500 inches wide" — about 96x bigger than intended. Fix: introduce TARGET_W_IN / TARGET_H_IN (20 / 11.25, i.e. 1920/96 and 1080/96) and rescale EMU through those instead of TARGET_W / TARGET_H. slide.width / slide.height stay at 1920 / 1080 because that's what the engine expects in slide.width. readStroke also assumed pixel units — Math.max(1, ...) clamped strokes to "at least 1 inch" after the scale flip, which would have produced impossibly thick lines once the rest of the fix landed. Now clamps to 1/96 inch (~1 pixel) minimum. After fix, the gallery_template_layout_set.pptx full-slide backing rectangle imports at exactly 20.00 x 11.25 inches with a 0.02 inch (~2 pt) stroke, which matches the source slide dimensions. |
||
|
|
5d86d9ced7 | Mark Phase 4 done in PPTX spec | ||
|
|
3def41b184 |
PPTX import Phase 4: basic preset shapes with fills + strokes
Walks each slide's p:cSld/p:spTree and turns every non-text-bodied
p:sp with a:prstGeom into an Artstr shape layer. After Phase 1+1.5
slides imported as colored backgrounds with no layers; after this
they come in with most of their visible design.
What landed in src/pptx-importer.js:
- PPTX_PRST_TO_ARTSTR table mapping rect, roundRect, ellipse,
triangle, line, straightConnector1, star5, hexagon, pentagon
to Artstr shape kinds. Unknown prst values fall back to rect
with an UNSUPPORTED_SHAPE_APPROXIMATED warning.
- convertColor: a:srgbClr → '#rrggbb'. Scheme / preset / system
colors return null and trigger theme-resolution warnings later.
- readFill: walks spPr for a:solidFill / a:noFill, returns an
Artstr fill descriptor. Direct-child-only lookups so a fill
nested in a:ln doesn't leak through.
- readStroke: walks spPr/a:ln for solid color + width (EMU→px via
the slide scale) + dash style. Maps the OOXML dash family to
Artstr's three states (solid / dashed / dotted).
- readXfrm: a:xfrm/a:off + a:xfrm/a:ext → {x,y,w,h} in slide-pixel
coords; a:xfrm@rot (1/60000 deg) → degrees. Skips zero-area
shapes.
- convertPresetShape: pulls the above together, names the layer
with the source p:cNvPr@name for traceability, skips shapes that
carry a p:txBody (Phase 2 will handle text shapes).
- walkSpTree: top-level walk of spTree children. p:grpSp is Phase
5; p:pic / p:graphicFrame are Phase 3.
- importPptxFile wires the walk into the per-slide loop; layer ids
use a per-import counter prefix so two imports can't collide.
Smoke-tested against the user's gallery_template_layout_set.pptx
(16 design-template slides):
- 16 slides, 16 backgrounds, 64 shape layers, 0 warnings.
- Slide 1 imports the cream backing rect + green rect + gold and
terracotta ellipses + two cream rounded rectangles, with their
exact source colors (#5e735d, #c8a658, #b05a44, etc.).
- Editor loads in deck mode with the layers visible.
Synthetic 3-slide pptx still produces white slides + 0 layers, so
no regression on the Phase 1 baseline.
|
||
|
|
32af7f0e96 |
PPTX import P1.5: slide backgrounds (direct solid fills)
Quick win on top of Phase 1: pull each slide's direct background color out of p:bg/p:bgPr/a:solidFill/a:srgbClr and set it on the Artstr slide instead of always leaving the default white. Scoped narrowly: - Direct slide backgrounds only. p:bgRef (layout/master inheritance) emits LAYOUT_INHERITANCE_PARTIAL and stays white — Phase 6. - solidFill + srgbClr only. Gradient, picture, and scheme-color fills emit THEME_COLOR_UNRESOLVED and stay white — Phase 6. - Parse failures on a per-slide basis emit SLIDE_PARSE_FAILED and fall through to white; the deck shell still imports. Counts successful backgrounds in report.imported.backgrounds so the import report can show "16 backgrounds imported". readSlideBackground is exposed via _internals for future-phase tests. Verified end-to-end against the user's gallery_template_layout_set deck (16 slides, design-template set heavy on background colors): all 16 slides now import with their actual cream / themed colors (e.g. slide 1 = #fbf8f0) instead of plain white, with zero warnings. Synthetic-pptx smoke test (no p:bg) still produces white slides. |
||
|
|
71e5370e36 | Mark Phase 1 done in PPTX spec | ||
|
|
2cc2b85883 |
PPTX import Phase 1: deck-shell parser
Replace the Phase 0 "not implemented" stub with a working deck-shell
import. A .pptx file in becomes a templateMode:'deck' payload out,
one empty Artstr slide per PowerPoint slide, with no layer extraction
yet (Phase 2+ adds text, notes, images, shapes).
What landed in src/pptx-importer.js:
- readPptxPackage(file): file -> ArrayBuffer -> fflate.unzipSync ->
{ [path]: Uint8Array }. Throws on missing fflate or invalid zip.
- parseXml(xmlText): DOMParser wrapper that detects <parsererror>
and re-throws so silent malformed-doc cases don't slip through.
- pptxTargetToZipPath(basePath, target): resolves OOXML relationship
Target values against the owning part's path. Handles relative,
absolute-in-package, and ./.. segments.
- readSlideSize(presDoc): reads p:sldSz cx/cy (EMU). Falls back to
16:9 widescreen default (12192000 x 6858000) when absent.
- readSlideRefsInOrder(presDoc, presRelsDoc): walks p:sldIdLst for
canonical order, resolves each r:id through the rels file to the
actual zip path of the slide XML. Handles both getAttributeNS for
the relationships namespace and the plain "r:id" attribute form.
- readPresentationInfo(files, report): bundles size + slide refs.
Adds a LARGE_DECK warning at >100 slides.
- importPptxFile(file, report): reads the package, parses
presentation info, emits one empty deck slide per ref at 1920x1080
with #ffffff bg + ignoreDeckTheme:true. Yields to RAF every 10
slides so a 50+ slide deck doesn't lock the UI. Returns a
templateMode:'deck' payload ready for loadProjectFromText.
Internals are exposed via window.ArtstrPptxImporter._internals for
future-phase tests and harnesses.
Smoke-tested via CDP against a synthetic .pptx built in-browser with
fflate.zipSync:
- 3-slide deck imports with correct count, dims, bg, ignoreDeckTheme.
- 5-slide deck round-trips through loadProjectFromText: editor
comes up with templateMode='deck', 5 slides, body has template-deck
class (Deck Builder live).
- Garbage bytes throw a clean "invalid zip data" error instead of
silently producing a zero-slide deck.
|
||
|
|
e8e96e2a3d |
Drop orphan vault-fix scaffolding left over from Phase 0 commit
The pptx-import branch's Phase 0 commit ( |
||
|
|
8f500f50af | Merge branch 'main' into pptx-import | ||
|
|
99d83e291a |
Make purchase vault durable across devices / domains
Fix three load-bearing gaps in the premium-templates vault that together made paid unlocks invisible on any session other than the one where the purchase happened (e.g. localhost vs. artstr.org with the same login). 1) Receipt fast-path now re-derives and decrypts the payload. The soft-gate KDF is deterministic from public material + the static client pepper, so a valid kind-9735 receipt is sufficient to recover the full decrypted payload locally — no surviving vault required. Previously the fast-path only set unlocked:true without the payload, so a card could appear unlocked but Use / Fork had nothing to fork. Now it runs Premium.decryptPayload on the envelope when a receipt matches by address (or event id), stores the full payload + preview in the local cache, AND opportunistically back-fills the vault so the next device doesn't have to redo the receipt scan. 2) Vault upsert is synchronous-to-success with visible failures. The unlock success path used to fire-and-forget upsert with .catch(console.warn). If the vault publish silently failed — relay rejection, narrowed relay list, etc. — the user still saw "Unlocked ✓" while no other device would ever see the purchase. Now the unlock awaits upsert, shows a "Syncing to your purchase vault…" interim toast, and on failure replaces the success toast with a clear error explaining the unlock is device-local only. upsertPurchaseVaultItem also stops swallowing publish errors so callers see them. 3) Vault traffic uses a wider relay union. New _vaultRelayUnion() returns the user's configured relays unioned with DEFAULT_NOSTR_RELAYS (deduped). _publishVaultEvent, loadPurchaseVault, and relayFastPathUnlockCheck all use it, so vault events stay reachable when a user narrows their relay list or runs the app on a different domain. NIP-65 write relays can be folded in later by editing one helper. Smoke-tested via CDP: - _vaultRelayUnion correctly merges + dedupes user + default relays. - Receipt fast-path now finds a synthetic kind-9735 receipt by `a`, decrypts the matching premium event end-to-end, and writes a cache entry with the full original payload (title, layers, etc). - upsertPurchaseVaultItem propagates publish failures instead of returning null — callers can now react to vault sync errors. Together these mean: an existing purchase will auto-recover the next time the Premium tab loads on any browser / domain that shares the same Nostr login, just from the receipts already on relays. No re-payment, no manual vault recovery. |
||
|
|
24fb160e99 | Mark Phase 0 done in PPTX spec; pausing for vault-fix work on main | ||
|
|
d710b84d19 |
PPTX import Phase 0: plumbing + lazy-load shell
Sets up the import-pipeline plumbing without doing any actual PPTX
parsing yet. Acceptance criteria from the spec's Phase 0:
- Vendor fflate (0.8.2 UMD) at src/vendor/fflate.min.js so the
importer can unzip .pptx packages without a build step. fflate
exposes window.fflate via the UMD shim.
- Add src/pptx-importer.js exposing window.ArtstrPptxImporter with
PPTX_IMAGE_PLACEHOLDER_URL, makePptxImportReport, and a stub
importPptxFile that throws "not implemented yet — Phase 1 lands
the deck-shell parser." Phase 1 will fill this out.
- Wire loadScriptOnce + ensurePptxImporterLoaded in index.html.
Both fflate and pptx-importer.js are lazy-loaded on the first
click of Import PPTX so the main bundle stays lean for users
who never use this path. Subsequent clicks are no-ops.
- Add an Import PowerPoint (.pptx) button + hidden file input to
the Deck Builder's Slides fieldset, next to the existing
Import slide buttons. Click flow:
button click -> ensurePptxImporterLoaded (with a "Loading…"
label on the button while scripts arrive) -> hidden file
input click -> change -> onPptxFileChosen -> import + report.
- Add the pptxImportReportModal shell with file name, summary
counts, placeholder note, and a collapsible warnings list.
Render is driven by the report data shape defined in spec
section 13. Close on background click or Close button.
Smoke-tested via CDP: first click lazy-loads fflate +
pptx-importer; subsequent calls reuse cached promises; file
picker triggers; Phase 0 stub returns the expected
not-implemented error which surfaces as a toast.
|
||
|
|
467fbcf5e4 |
Draft PPTX import spec
Adds docs/PPTX_IMPORT_FEATURE.md as the canonical plan for the next big arc: a phased editable .pptx importer that lands PowerPoint decks as editable Artstr deck JSON. Key decisions baked in: - Images and unsupported objects (charts / SmartArt / tables / video) import as placeholder image layers pointing at a hardcoded URL (postimg.cc for now). Original PowerPoint media filenames carry over into the layer name so the user can correlate placeholders with their source deck. - fflate (~10 KB) vendored as src/vendor/fflate.min.js. JSZip is too heavy for the single-file ethos. - Importer code lives in src/pptx-importer.js as an IIFE that exposes window.ArtstrPptxImporter. The Import PPTX button dynamically injects both scripts on first click, so the main index.html bundle does not grow for the 90 % of users who never import a PPTX. - Notes folded into Phase 2 alongside text (single extra relationship lookup, Presenter Mode already renders slide.notes). - No Blossom / NIP-96 prereq, no base64 bloat, no publish-size guard. Imported decks are normal Artstr deck payloads with remote URLs. - Import-only for this arc. PPTX export (Artstr -> .pptx) is a separate later arc with its own complexity. TODO.md gets a Drafted-but-unbuilt entry pointing at the new spec. |
||
|
|
3b47a314c0 |
Catch up TODO + README + spec banner on the premium-templates arc
Pre-merge docs sweep before zap-gated lands on main: - TODO.md: move Premium out of "Drafted-but-unbuilt" into "Shipped (recent)" with a full summary of what landed (soft-gate, watermarked previews, NWC split-zap with 30% platform fee, purchase vault with auto-split, profile menu, login picker, Settings, mobile polish). Leave Phase 4 E2E + a few confirm()-modal replacements as a new "Premium-templates polish" section. - docs/PREMIUM_DESIGNS_FEATURE.md: flip the status banner to "Shipped on zap-gated, merged into main 2026-05-29" and map each spec step (0-4) to where it lives in src/. - README.md: add a Premium encrypted designs section; mention NWC, vault, login picker, profile dropdown, and Settings modal in the Nostr / Lightning sections; drop "NWC zaps / zap-gated templates" from the unbuilt list in the Files section. |
||
|
|
1c3160ca74 |
Mobile polish for the community browser, cards, and preview pane
Adam: "Take a pass at the site and improve the overall mobile
look. Focus on the template browser and the cards and the design
previews."
Mobile-before screenshots (iPhone-14 viewport) showed three real
problems:
1. Modal-tabs row overflowed the screen edge — Search and My
Designs cut off; users couldn't reach them.
2. Type-filter chips + category + language + trust selects all
wrapped to 3-4 rows, eating ~150px of vertical space before
a single card showed.
3. Preview-pane header layout squeezed the title into a tall
narrow column on the left because the action row insisted on
filling the right.
All of these stay desktop-first; the editor itself isn't optimised
for phones (Adam: "I don't expect users to use mobile to create
designs as much as just viewing what's been created"). The new
@media (max-width: 600px) block tightens only the community-
browsing surfaces:
Modal sizing
- Cards / inner content now use min(98vw, 640px) — 2vh margins
instead of 4vh, so the modal uses the screen rather than
floating in dead space.
- Padding reduced. Close button moves a few px tighter to the
corner.
- #nostrModal .modalHeader switches to flex-direction:column so
the title sits on its own row above the actions. Fixes the
"title squished into a narrow column" symptom.
Horizontal scroll instead of wrap
- .modalTabs and .feedTypeFilter both go flex-wrap:nowrap with
overflow-x:auto. Users swipe through the tabs / chips
horizontally, which is the standard mobile gesture for crowded
nav. Thin custom scrollbar tracks.
Cards
- One column. Tighter gaps (10px).
- Thumbs cap at height:260px / max-height:60vw — without this,
a 360px-wide phone gives a 360px-tall square thumb that fills
the entire viewport with one card.
- Card-actions row gets smaller font + tighter padding so the
four buttons (▲ Preview Use/Fork ⋯) stay on one row at common
titles.
Preview pane
- Tighter padding inside the gradient stroke (10px instead of
14px) so the preview content has more room.
- Ribbon slightly smaller font on mobile to match the new
proportions.
Verified visually at 390x844 (iPhone-14): tabs scroll cleanly,
chips scroll cleanly, the Premium card fills the column with its
watermarked JPEG + corner ribbon + Unlock CTA in the right
proportions, and the preview pane stacks title-above-actions
without crushing either. Regression smoke (existing editor) still
passes at desktop widths.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
92661a11a9 |
Preview pane: gradient stroke + PREMIUM ribbon to match the feed card
The preview pane (the larger view that opens when you click a feed
card's preview) already shows the watermarked JPEG + the Unlock
CTA for premium designs, but it lacked the visual cue that made
premium cards stand out on the wall.
CSS
- #nostrPreviewPanel.premiumPreviewPane reuses the same 2px
gradient-stroke trick as .feedCardPremium: two layered
background images (solid panel + 135° yellow→magenta→cyan)
with background-clip:padding-box,border-box on a transparent
2px border. Plus the same soft outer glow.
- Dark-mode variant swaps the inner panel colour.
- New .premiumPreviewRibbon — same gradient pill as
.feedCardPremiumRibbon but slightly larger to suit the bigger
surface; positioned top:10 left:10 inside the pane.
JS
- openDesignPreview adds class premiumPreviewPane and appends
the ribbon span when row.premium is set. Removes the class
on non-premium rows so styling doesn't stick from a previous
preview.
- Ribbon text mirrors the feed card: "⚡ PREMIUM" when locked,
"⚡ UNLOCKED" when the design has been paid for / decrypted.
Tested via CDP: preview pane opened on a synthetic premium event
has classes "premiumPreviewPane", ribbon text "⚡ PREMIUM", and a
linear-gradient in the computed background-image. Screenshot
confirms the same visual treatment as the feed card.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
1ea611b44e |
Feed cards: rebuild when the author's profile arrives (so the Tip button shows)
Adam reported the ⚡ Tip button was missing from cards in the template browser even though the author had a Lightning address. Root cause: diffWallCards reuses an existing card by event id and never rebuilds it. The author's profile is fetched AFTER the template events in fetchFeedAndReactions (the enrichment step), so the first card render runs when _feedCache.profiles[author] is still undefined. makeFeedCardTipButton returns null in that case (no lud16 → no tip button). When the profile arrived later, notifyFeedProgress kicked off a rerender, but diffWallCards reused the tip-less DOM card unchanged, and the button never appeared. Fix - buildFeedCardForRow now stamps each card with a small string encoding the row's mutable view-state: profile-has-LN flag, reaction count, zap-total bucket, premium decrypted/locked state, broken-preview flag. - diffWallCards reads the stamp on the existing card and the fresh stamp on the new row. If they differ, it rebuilds the card and replaceWith() into place. Cards built through paths that don't set a stamp (legacy code paths) are reused as before — backwards-compatible. Same mechanism naturally handles other "card refreshes when…" cases that previously didn't: when zap receipts stream in (zap bucket changes), when a reaction count ticks up (rx changes), when a premium row gets unlocked (pd/pg flip), when a previously- broken image gets retried and works (b flips). Tested via CDP: card built with empty profiles cache → no tip button, stamp ln=0. Profile pushed into the cache with lud16 → rerender → stamp ln=1, card rebuilt, "⚡ Tip" button now present. The same root cause was theoretically present in the preview pane and profile page, but in practice those open AFTER the user has been on the feed for a few seconds (profiles already loaded), so this card-side fix is the user-visible one. If the preview pane ever lands first, the openDesignPreview path already reads the latest _feedCache.profiles every time, so it picks up the LN address from the next render. Co-Authored-By: Claude Opus 4.7 <[email protected]> |
||
|
|
34d5918002 |
Profile menu + Settings modal + Logout
Restructures the post-login surface so Relays / Trust / Blocked
authors / Lightning wallet live in their own modal instead of
buried at the bottom of the Community Templates browser, and adds
a Logout action that was previously missing.
Profile button → dropdown menu
- Clicking the avatar pill toggles #nostrProfileMenu with three
items: 👤 View profile, ⚙ Settings, ↩ Logout. Menu is
position:absolute under the .nostrWidget (which is already
position:fixed, so it serves as the positioning context — no
new wrappers needed).
- openProfileMenu installs a document-level click-away handler
(deferred via setTimeout(0) so the opening click doesn't
immediately close it) and an Escape handler. closeProfileMenu
tears both down.
- 👤 View profile → existing openProfilePage(myPubkey).
- ⚙ Settings → opens the new Settings modal.
- ↩ Logout → clears local Nostr identity (see below).
Settings modal (#settingsModal)
- Standard modal shell with a close × button. Four <details>
sections moved verbatim from the Community-templates browser:
Relay settings, Web-of-trust soft moderation, Blocked authors,
⚡ Lightning wallet. Same IDs, so every existing handler (the
softModEnabledToggle, walletConnectBtn, walletSyncToggle,
relayList input, the whole walletUI module) keeps working
without changes.
- Backdrop click + Escape close the modal.
Logout (nostrLogout)
- Clears state.community.myPubkey + myProfile + myFollows +
myExtendedFollows + muteListSyncedAt.
- Drops the localStorage premium-unlock cache (it's per-identity)
and resets _vaultCache so the next sign-in pulls a fresh copy.
- persistAutosave + updateNostrWidget so the UI flips back to
"Sign in with Nostr."
- Does NOT disconnect the wallet (deliberate: the wallet is
reusable across identities, and Settings → Disconnect handles
that explicitly).
- Does NOT delete the synced-wallet record on relays (deliberate:
the whole point of NWC sync is to make the next sign-in pick
it back up). User can untick "Sync this wallet" in Settings
if they want a clean slate.
- Toasts "Signed out of Nostr."
Side fixes from the move
- Removed two dead nostrRelayDetails style.display toggles in
openDesignPreview / closeNostrPreview — that element used to
live inside the Community modal, so hiding it kept the layout
tight. Now it's in Settings; toggling its display from those
spots was a bug-in-waiting (the Settings modal would render
its relay section invisible the next time someone opened it
after closing a preview).
- openUnlockConfirm's "no wallet connected" path now opens the
Settings modal + expands the wallet panel, instead of the old
"open community modal" trick.
Tested via CDP:
- Signed-in: profile pill visible, login button hidden, menu
starts closed.
- Click pill → menu opens with three items in order.
- Click Settings → modal opens, menu closes; modal contains
nostrRelayDetails, blockListDetails, walletDetails sections.
- Close × → modal hides.
- Click pill → menu opens → Logout → state.community.myPubkey
cleared, profile pill hidden, login button visible.
- Regression smoke still passes.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
8c77e547dc |
Login picker modal — NIP-07 active, NIP-46 and nsec stubbed for later
Clicking the "Sign in with Nostr" button used to call window.nostr
directly. Now it opens a small picker so users see the three signer
options Artstr plans to support, with only NIP-07 actually wired up
today.
Picker modal (#loginPickerModal)
- Three option cards stacked: Browser extension (NIP-07), Remote
signer (NIP-46), Paste private key (nsec). Each card shows icon
+ name + a one-line description of the security model.
- NIP-46 + nsec cards rendered at .55 opacity with a "Coming soon"
pill, but still clickable so the toast fires and the user knows
we noticed they tried.
- "New to Nostr? Get started at nostr.how" link at the bottom for
onboarding.
JS wiring
- openLoginPicker / closeLoginPicker — standard show/hide. Backdrop
click closes (matches the publish-confirm modal pattern).
- loginOptNip07 → closes picker + runs the existing nostrLogin().
- loginOptNip46 / loginOptNsec → toast "Coming soon" explainer.
- $('nostrLoginBtn').addEventListener('click', ...) re-routes to
openLoginPicker; old direct-nostrLogin wiring removed.
When NIP-46 and nsec ship later, the picker options just need their
disabled styling dropped and new handlers wired — the modal markup
+ open/close plumbing stay.
Tested via CDP: picker hidden by default, opens on login-button
click, NIP-07 option closes picker and calls nostrLogin, NIP-46
option fires the "Coming soon" toast and leaves nostrLogin alone.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
be8c55afc8 |
NWC sync: store wallet credential as a NIP-44 encrypted Nostr event
Adam asked: "can we make the NWC link sync across logins via an
encrypted note?" Yes — common pattern in the Nostr ecosystem
(Alby's QR-shared wallet connect, Mutiny's wallet-pubkey export,
etc.) Mirrors the same NIP-44-self-encrypted kind-30078 shape we
already use for the purchase vault.
New module
- NWC_SYNC_D_TAG = 'artstr:nwc-connection:v1'
- NWC_SYNC_PREF_KEY = 'casewrap-nwc-sync-pref' — local preference
for whether to push/pull (default ON).
- publishNwcSync(uri) — NIP-44/04-encrypts the connection string
via the existing _selfEncrypt helper, publishes kind-30078 with
d=artstr:nwc-connection:v1 + encrypted tag. Fire-and-forget;
failures don't block the local connection.
- loadSyncedNwcUri() — queries the user's relays for their own
sync event, decrypts via _selfDecrypt, returns the URI string
or null. Validates the URI prefix before returning.
- deleteSyncedNwcUri() — publishes a replacement event with no
`uri` field so subsequent loads return null. Cleaner than a
kind-5 deletion for a replaceable parameterized event.
- hydrateNwcFromSync() — runs on Nostr login; restores the
connection only if the sync pref is on AND there's no local
connection AND a synced URI exists. Toasts "Lightning wallet
restored from your Nostr account ✓" on success.
UI
- Wallet connected-state panel gains a "Sync this wallet to my
Nostr account…" checkbox with an inline disclosure of the
security model: NIP-44 encrypted to your own pubkey, only
decryptable with your nsec. Hidden when not signed in to
Nostr / no encryption support.
- Toggle ON → re-publishes the current URI to the user's
identity. Toggle OFF → deletes the synced record and clears
the pref so the next sign-in elsewhere won't auto-restore.
- handleConnect: after a successful nwc.connect, if the sync
pref is on, publishes the URI in the background; toasts on
success.
- handleDisconnect: also clears the synced copy so a sign-in on
another device doesn't immediately reconnect a wallet the
user just intentionally disconnected.
- render() reflects the toggle's current state from the pref.
Hook into login
- loadCommunityIdentity now ALSO fires hydrateNwcFromSync after
the existing vault hydrate (both background, both
non-blocking). New device → sign in → wallet restored without
pasting the NWC URI again.
Security caveats surfaced in the UI copy:
- The connection string is a wallet-spending credential. Anyone
with the user's nsec can decrypt + spend up to the wallet's
NWC budget. Same trust model as the rest of the user's Nostr
identity, but worth being explicit since this is a hot wallet.
Tested via CDP with stubbed window.nostr.nip44 (base64 round-trip)
and stubbed publishCommunityEvent / queryRelays:
- Publish creates kind-30078 with the right d-tag + encrypted
marker.
- URI round-trips through encrypt → decrypt.
- hydrateNwcFromSync calls nwc.connect with the exact URI.
- Toggle off → delete event published, subsequent load returns
null.
- Pref defaults on, persists across get/set.
- Regression smoke still passes.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
39dee5157a |
New tab: sort by recency, not by trust tier
Adam reported the latest design (a premium one) was showing up about halfway down the New tab. The New-tab sort key was: (broken) || (trust tier weight) || (createdAt desc) So a recent post from someone you don't follow ranked BELOW older posts from people you do follow. That's the right behaviour for a "Following" tab, not a "New" tab — users expect newest-first when they click New. Trust tier removed from the New-tab sort. Recency wins, with broken-preview still as the only soft demotion so a wall of missing images doesn't lead. The existing Trust dropdown on the filter row is already there for users who want to scope to follows / extended-follows; this change just stops it from being silently the dominant sort. Other tabs are unchanged: Popular still uses zap-total → reactions → trust → createdAt, Zapped uses zap-total → trust → createdAt, Premium uses createdAt → unlock-min. Co-Authored-By: Claude Opus 4.7 <[email protected]> |
||
|
|
e6588017ed |
Premium: fix three issues from Adam's testing
1) Premium designs missing from the "New" tab
isPreviewBroken was flagging every premium cover / disc / jewel /
disc-design row as broken: those modes are in MODES_REQUIRE_IMAGE,
the stub payload has no slot/disc art, so row.preview was '' →
"broken". The New tab's sort puts broken rows last, so premium
rows sank past the 24-card pagination cutoff and disappeared.
Premium rows carry their own watermarked-JPEG preview inside the
envelope and always show it on the card, so they should never
count as "broken." isPreviewBroken now short-circuits on
row.premium and returns false unconditionally.
2) Upvote button felt unresponsive
The reaction handler awaited publishToRelays before changing the
button text — on slow relays that's a few seconds of no visible
feedback, so the click looked like it did nothing. Now it
optimistically swaps to "⋯" + disables the button as soon as you
click, then either commits to "▲ N" on success or rolls back to
the original text on failure. No more "did it work?"
3) My Designs → Purchased tab loaded inconsistently
Previously force-refetched the vault from relays every time the
tab opened, blocking the render on the slowest relay in the
pool. If the fetch failed, the whole wall went to an error
placeholder. Now:
- If we have an in-memory _vaultCache from the current
session, render those cards IMMEDIATELY (sync, before the
await), then re-fetch in the background and re-render with
the latest. Feels instant in the common case where the user
just unlocked something.
- Top-of-list refresh button so the user can force a relay
re-query on demand (useful after unlocking on another
device).
- Items missing their per-item event (the split-vault
_missing flag) render at .55 opacity with a click-to-toast
explanation instead of silently failing.
- On refresh failure, keep showing the cached cards — don't
blow them away just because relays timed out — and surface
a toastError with the actual reason.
Tested via CDP:
- Premium cover row → isPreviewBroken = false ✓
- Reaction button: click → text becomes "⋯", disabled = true.
On simulated failure response → rolls back to "▲", re-enabled.
- Existing vault round-trip + hydrate + Purchased-tab regression
still passes.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
b6b189848a |
Premium: parallel pay legs + address-tolerant unlock lookup + hydrate toast
Two fixes for issues Adam hit while testing.
Speed (~20s unlock → ~10s)
- payZapSplit was paying creator first, then platform, sequentially.
Each NWC pay_invoice round-trip is 3–8 s (LNURL fetch + wallet
settle + RPC response), so the two pays dominated wall time.
- Switched to Promise.allSettled — both legs fire concurrently
over the same NWC channel. The per-leg lookup_invoice + cached
preimage logic already protects against double-pay on retry,
so the original "creator first if half fails" ordering wasn't
actually load-bearing for correctness.
- On failure: surface the right `err.stage` so the UI's
targeted-retry button still works. If both legs fail we bubble
the creator error.
- Status line says "Paying creator + platform fee…" so the user
sees one combined step instead of two.
Cache-clear unlocks restored from vault (address-tolerant matching)
- Adam reported: "I clear cache and reload, the premium design I
just paid for becomes locked again." Two causes:
1. The hydrate runs silently — no visible feedback that
anything happened, so it's invisible whether it's broken
or working.
2. The lookup was by event-id only. If the creator edit-in-
places the design, the new event id won't match the
stored unlock entry → re-locked.
- isUnlockedLocally(eventId, addressCoord) now checks the event-
id first (fast path), then scans for an entry whose `address`
matches. New helper findUnlockedEntry returns the full entry
the same way.
- buildRowsFromEvents passes premiumAddressCoord(e) so an old
vault entry under the OLD event id still unlocks a NEW event
at the same address.
- hydrateUnlockedFromVault now skip-checks BOTH event id AND
address, returns { restored, total }, and surfaces a toast on
success: "Restored N premium unlock(s) from your vault ✓".
Failures surface a toastError so the user sees what went
wrong instead of guessing.
Tested via CDP:
- Address-tolerant lookup: pre-stamp an unlock for event v1 with
address coord A. Build rows from event v2 (same A, different
id) → v2 rendered as unlocked. Build rows from event v3 with
a different address → still locked. Lookup by event id vs by
address vs neither all return the right answers.
- Existing preview-refresh E2E (which exercises the full payZap
Split + unlock + decrypt path through the parallel-pay branch)
still passes — lock CTA → Use/Fork, watermark → real preview,
reveal flash applied.
- Regression smoke still green.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
f1a147baf9 |
Premium preview now supports pixel-art mode
Adam asked: "why is 'Premium preview not supported for mode pixelart'?"
— it's because I excluded pixelart and deck from the supported-modes
dispatcher when shipping the watermarked-preview pipeline. Pixel art
has its own renderer that operates on a tiny native canvas (32–128
px on a side), so the default JPEG-export path didn't apply.
renderPixelArtToExportCanvas()
- Renders the standalone pixel-art design (state.pixelArt frame 0)
through the existing renderPixelGridToCanvas helper.
- Scales up by the largest integer multiple that keeps the long
axis ≤ PREMIUM_PREVIEW.MAX_LONG_AXIS_PX (720 px). Integer scale
+ imageSmoothingEnabled = false → pixels stay crisp instead of
blurring; 32 px → 22× → 704 px, 64 px → 11× → 704 px, etc.
- Wired into _renderModeToExportCanvas so renderPremiumPreviewDataUrl
accepts pixelart alongside cover / disc / jewel / disc-design /
customart / slide.
Tested via CDP on a synthetic 32×32 frame:
- returns ok: true, 562×562 px, 45 KB. Reads as crisp pixel art
under the diagonal "ARTSTR PREMIUM · UNLOCK TO USE" watermark
in the saved JPEG.
Deck mode still throws ("Premium preview not supported for mode
'deck'") — the multi-page deck export is a different beast. A
deck-cover thumbnail would need the deck-builder code path; left
for a future commit if creators ask.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
1bb4471cc8 |
Fix: feed rerender silently dies when autosave restores myExtendedFollows as {}
Adam reported the "All" tab in the template browser was empty even
after the fingerprint dedup fix. Root cause: JSON cannot represent
a Set, so when autosave serialises state and the user reloads,
state.community.myExtendedFollows comes back as an empty object {}.
buildRowsFromEvents calls trustTierForPubkey on every event, which
calls state.community.myExtendedFollows?.has(...) — the ?. chain
DOES NOT short-circuit on a non-null object that just doesn't have
a `.has` method, so it throws TypeError. Inside the row builder
loop the throw bubbles up through dedupByFingerprint and the
rerender exits before any cards are drawn, leaving the wall empty
with no console error the user would notice. Compounded by my
recent additions writing autosave more often (after login,
hydrate, etc.), which made the bad shape much more likely to be
present on subsequent loads.
Three defensive fixes (any one would have sufficed; all three are
cheap):
1. restoreAutosave() re-initialises state.community.myExtendedFollows
to a real Set after the autosave merge if the restored value
isn't a Set. Same for myFollows → []. fetchHop2Follows refills
myExtendedFollows on next login.
2. trustTierForPubkey() now checks `ext instanceof Set` before
calling .has, and guards myFollows behind Array.isArray. Same
idea — refuse to throw on a shape that wasn't a Set / Array.
3. The feed-tab rerender wrapper try/catches the inner render
and console.warns instead of bubbling. Without this guard, a
single throw anywhere in the row builder / dedup / sort /
diff path silently kills the whole feed and the user has no
way to see what went wrong.
Tested via CDP: stuff autosave with state.community.myExtendedFollows: {},
reload, confirm state.community.myExtendedFollows is back to a Set
and buildRowsFromEvents on a synthetic event succeeds (trust
returned as 'unknown' since the test pubkey isn't followed). Pre-
fix this test threw the TypeError shown above.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
95a7130255 |
Fix: premium events were all collapsing into one row via fingerprint dedup
Adam reported the "All" filter in the template browser was empty. The
root cause: when buildRowsFromEvents synthesises a stub payload for a
still-locked premium row (we can't see the encrypted content), the
stub has no layers / slots / canvas data — so designFingerprint
returns essentially the same string for every premium row of the
same template mode. dedupByFingerprint then collapses them all into
a single row whose title happens to come from the earliest createdAt
event. With a feed full of premium designs and no plain ones, the
"All" view looked nearly empty.
Fix: designFingerprint short-circuits and returns '' (treated by
dedupByFingerprint as "skip dedup, always keep this row") for any
payload that has `premium: true` AND no content-bearing fields
(layers / coverTemplate / discTemplate / customArt / etc). That
matches exactly the stub shape produced by
_stubPayloadFromPremiumEvent — once the user unlocks and the cached
decrypted payload replaces the stub, the row has its real fields
back and normal content-based dedup resumes.
Tested via CDP:
- 5 premium events from different creators with different d-tags
and titles → all 5 rows survive the rerender → all 5 cards
visible. Previously this would have been 1 card.
- The earlier mixed test (1 plain + 1 premium + 1 legacy) keeps
the same 2-card result (plain + legacy DO have matching
fingerprints because their plaintext is identical) — exactly
what dedup is supposed to do for non-premium events.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
c87ec907ef |
Premium: honest soft-gate copy in unlock confirm + publish ack
PREMIUM_DESIGNS_FEATURE.md §9.1 + §13.5. Two surfaces where the
honest framing was missing:
Unlock confirm dialog (buyer side)
- Added a trailing line under the price breakdown:
"Soft-gate, not strong DRM — once unlocked you can save /
export / share the design like any other Artstr file."
- The buyer sees the limitation BEFORE paying so the
expectation is set up-front: the design will be theirs to
use, and there's no client-side trick that prevents them
from re-sharing afterwards.
Publish modal ack checkbox (creator side)
- New #publishConfirmSoftGateAck checkbox inside the premium-
details panel: "I understand this is a static soft-gate,
not strong DRM. A determined buyer can extract / re-share
the design after unlocking. Artstr protects against
drive-by scraping, not piracy."
- handlePublishConfirmPublish hard-blocks the publish when
state.publish.zapGate is on and the ack isn't ticked,
routing through the existing publishConfirmError surface.
- updatePublishConfirmZapGate resets the ack to unchecked on
every modal open so creators have to re-acknowledge each
time. Inheriting a tick across publishes would defeat the
point.
Tested via CDP: ack present, initial state unchecked, Publish
without tick produces the corrective error and does NOT call the
onConfirm callback. Tick + Publish succeeds (onConfirm fires).
Regression smoke still passes.
The remaining honest-copy surfaces (publish modal toggle label,
preview-pane locked note, Premium tab hint) were already in
place from earlier steps. The wallet panel itself doesn't get
the soft-gate copy — NWC is general-purpose for tips + zaps,
and the soft-gate framing belongs where the creator is
publishing or the buyer is paying.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
68b696459e |
Vault: auto-split into per-item events when over 40 KB
PREMIUM_DESIGNS_FEATURE.md §8.6 future-mitigation. Closes the
size cap on the single-event vault: once the encrypted blob would
exceed 40 KB (chosen to give comfortable margin under the ~64 KB
content limit on the most-common relays), publish each purchase
as its own kind-30078 event and reduce the canonical vault event
to a small manifest of stubs.
Implementation
- _vaultItemDTag(addr) — sha256(addr) → base64url, prefixed
`artstr:purchase:`. Stable per address coordinate, so a
re-publish of the same vault item over-writes its event in
place. Uses window.ArtstrNoble.sha256 (already bundled).
- _vaultItemStub(item) — drops payload + previewJpegDataUrl +
receipts, keeps only address coord + title/mode/amount/
timestamp so the index stays cheap. Stubs are enough to
populate the "My Purchases" tab even before the loader has
fetched per-item events.
- _publishVaultEvent(plaintext, dTag, extraTags) — extracted
helper that NIP-44-encrypts + publishes any vault-like event
(the canonical index OR a per-item entry). Returns
{ signed, encryptedSize } so the size probe doesn't require a
second encrypt.
- _publishVault now probes the single-event size FIRST. If
under threshold it publishes as before. Otherwise it loops
over items, publishing one per-item event each
(d=artstr:purchase:<sha>, t=casewrap-purchase tag for
discovery), then publishes a small canonical index event
with `split: true` + stubs only. Console.info logs the trip.
- loadPurchaseVault detects `parsed.split === true`, computes
the expected per-item d-tag for each stub, runs one bulk
queryRelays with #d=[...] to fetch them all in one shot, and
merges each per-item payload onto its stub. Stubs whose
per-item event can't be found come back with _missing: true
so the UI can render them as unavailable without crashing
the whole list.
- VAULT_SIZE_THRESHOLD constant: 40 * 1024. One place to tune.
Tested via CDP with a stubbed window.nostr.nip44 (predictable
base64 round-trip) and publishCommunityEvent / queryRelays:
- 1 small item (28 KB encrypted) → 1 event with the canonical
d-tag, content still inline. Reload restores the item.
- Add items until total would exceed threshold → console logs
"[vault] single-vault size ... > threshold ...". Subsequent
publishes emit per-item events (3 in this fixture) PLUS the
new index (1 KB, split=true, stubs only).
- Stubs in the index contain only a / eventId / creatorPubkey /
title / mode / unlockedAt / amountSats — no payload, no
preview.
- Loader reconstructs the full items list with payload +
receipts.
- Per-item event removed → reload still returns the stub but
flagged _missing: true.
- Regression: existing purchase-vault test (single-vault path
with NIP-44 round-trip + hydrate + Purchased tab render)
still passes unchanged.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
99b386db54 |
Premium unlock: refresh preview pane in place with a reveal flash
Previously, after clicking Unlock from the preview pane, the pane
kept showing the watermarked locked version until the user closed
and re-opened it. That made the "I just bought this" moment feel
inert, and could read as "did the payment work?" friction.
Now, on a successful unlock from openUnlockConfirm:
- Check if #nostrPreviewPanel is currently showing the row we
just unlocked (it stashes the row reference on the panel).
- If so, rebuild the row from _feedCache.events so it reflects
the freshly-decrypted payload (passes skipVersionFilter so the
cache stays fresh even for legacy version fields), and call
openDesignPreview(freshRow). The pane re-paints: the action
rail's "⚡ Unlock for N sats" swaps to "Use/Fork", the
watermarked JPEG body swaps to the real renderTemplate /
renderCustomArt etc. preview DOM, and Save JSON re-enables.
- Apply a new .premiumRevealFlash class to the panel right
after the swap. Defined as a 0.55 s premiumRevealFade
keyframe that fades from opacity 0 + 0.985 scale + slight
brightness boost to fully visible — reads as "the content
just appeared" without being garish.
Failure cases stay non-blocking: a try/catch around the preview
refresh logs to console without affecting the rest of the unlock
toast or the feed rerender.
Tested via CDP: synthetic premium event in the cache, open
preview pane (locked: watermarked image + Unlock CTA),
stub payZapSplit + upsertPurchaseVaultItem + native confirm so
openUnlockConfirm runs through. After the call returns the
preview pane has Use/Fork button, no JPEG image in the body, and
the premiumRevealFlash class set.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
191d216084 |
Premium tab in template browser + gradient stroke on premium cards
UI work to make premium designs more discoverable and visually distinct from free designs. New "⚡ Premium" tab in the Browse Community Templates modal - Sits between Zapped and Search in the tab row. - Reuses the existing feedTypeFilter / category / language / trust filter row shape. - panelPremium also carries an honest hint line under the filter row: "Premium encrypted designs — pay to unlock the full editable design. Soft-gate (not strong DRM); creators get 70%, Artstr gets 30%." - _feedState + _feedPagination extended with Premium. - switchModalTab + refreshNostrBrowser both recognise the new tab and route to loadFeedTab('Premium'). - loadFeedTab rerender filters out non-premium rows on this tab and sorts newest-first (cheap unlock-min as tie-break), so the tab reads like a marketplace listing rather than a chronology of mixed events. Gradient stroke + corner ribbon on premium cards - New .feedCardPremium CSS class. Uses the background-image double-layer trick (one solid bg + one gradient on border-box) to render a 2 px gradient ring through the standard border, plus a subtle outer glow. Dark-mode variant swaps the inner panel colour. - Gradient: 135° yellow (#ffd166) → magenta (#ef476f) → cyan (#06d6a0). Reads as Lightning-themed without screaming. - New .feedCardPremiumRibbon — small pill in the thumb's top -left corner. Shows "⚡ PREMIUM" when locked, "⚡ UNLOCKED" when the row's decrypted (purchase vault / fresh unlock). - buildFeedCard applies both class and ribbon whenever row.premium is set, so the visual treatment shows up on every tab (New, Popular, Zapped, Premium, Mine) — premium designs are easy to spot in the mixed feed too. Tested via CDP with one synthetic premium event + one plain event in the feed cache: - tabPremium / panelPremium / feedWallPremium all present. - Premium tab shows only the premium event (premiumOnly check on the synthesised event id). - Card class includes feedCardPremium; ribbon reads "⚡ PREMIUM". - On the New tab BOTH cards render, premium card has the styling + ribbon, plain card doesn't. - Pagination state.Premium populated correctly. - Regression smoke (existing editor) still works. Co-Authored-By: Claude Opus 4.7 <[email protected]> |